mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-15 05:30:03 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19222f9cc9 | ||
|
|
e6e68f8d92 | ||
|
|
4c56b05894 | ||
|
|
a107ad22f8 | ||
|
|
3e2e27b77e | ||
|
|
2babfa6ced | ||
|
|
e9c52aa8cb | ||
|
|
26ebeb8618 | ||
|
|
e5634c3204 | ||
|
|
14885c9a0e | ||
|
|
918ad405b6 | ||
|
|
5368e2dad3 | ||
|
|
5a9ec0581c | ||
|
|
726de0fe63 | ||
|
|
7d0b2891b9 | ||
|
|
4c2cb51c59 | ||
|
|
b796b4d2cd | ||
|
|
16f64e3982 | ||
|
|
4772287fe1 | ||
|
|
163285cf32 | ||
|
|
d5f4a6572b | ||
|
|
bca9d0af55 | ||
|
|
ed5bdad63f | ||
|
|
47a13c8674 | ||
|
|
0e28260197 | ||
|
|
edd399c079 | ||
|
|
460f629925 | ||
|
|
3970bbeb10 |
@@ -186,6 +186,16 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Checkout Windows test harness tooling
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: .ci-tools
|
||||
sparse-checkout: scripts/ensure-test-common-controls.ps1
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
@@ -221,9 +231,33 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check frontend types and regressions
|
||||
run: |
|
||||
pnpm --dir apps/desktop typecheck
|
||||
node --test apps/desktop/tests/*.test.mjs
|
||||
|
||||
- name: Verify Windows SDK test tooling
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: |
|
||||
command -v openssl
|
||||
openssl version
|
||||
|
||||
- name: Test Windows filesystem compatibility
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
shell: pwsh
|
||||
run: |
|
||||
$artifacts = Join-Path $env:RUNNER_TEMP 'codexx-test-artifacts.json'
|
||||
cargo test --release --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked --no-run --message-format=json > $artifacts
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$harness = ./.ci-tools/scripts/ensure-test-common-controls.ps1 -CargoJson $artifacts -OutputDirectory $env:RUNNER_TEMP | ConvertFrom-Json
|
||||
Write-Host "Running all tests in $($harness.testExecutable)"
|
||||
$env:PATH = "$(Split-Path -Parent $harness.testExecutable);$env:PATH"
|
||||
Push-Location apps/desktop/src-tauri
|
||||
try {
|
||||
& $harness.testExecutable --test-threads=1
|
||||
$testExitCode = $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
if ($testExitCode -ne 0) { exit $testExitCode }
|
||||
|
||||
- name: Import Apple signing certificate and notarization key
|
||||
if: matrix.macos
|
||||
|
||||
@@ -39,7 +39,36 @@ jobs:
|
||||
cache-on-failure: true
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --dir apps/desktop typecheck
|
||||
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
- run: node --test apps/desktop/tests/*.test.mjs
|
||||
- name: Verify SDK TLS fixture tooling
|
||||
run: |
|
||||
$openssl = Get-Command openssl -ErrorAction Stop
|
||||
& $openssl.Source version
|
||||
- name: Run Windows release tests
|
||||
run: |
|
||||
$artifacts = Join-Path $env:RUNNER_TEMP 'codexx-test-artifacts.json'
|
||||
cargo test --release --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked --no-run --message-format=json > $artifacts
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$harness = ./scripts/ensure-test-common-controls.ps1 -CargoJson $artifacts -OutputDirectory $env:RUNNER_TEMP | ConvertFrom-Json
|
||||
Write-Host "Running all tests in $($harness.testExecutable)"
|
||||
$env:PATH = "$(Split-Path -Parent $harness.testExecutable);$env:PATH"
|
||||
Push-Location apps/desktop/src-tauri
|
||||
try {
|
||||
& $harness.testExecutable --test-threads=1
|
||||
$testExitCode = $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
if ($testExitCode -ne 0) { exit $testExitCode }
|
||||
- name: Diagnose failed Windows test loader
|
||||
if: failure()
|
||||
run: ./scripts/windows-dll-diagnostics.ps1 -RunHarnessList
|
||||
- name: Retain Windows loader diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-loader-diagnostics
|
||||
path: ${{ runner.temp }}/codexx-dll-diagnostics
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
- name: Build test installer without publishing or release signing credentials
|
||||
run: |
|
||||
Set-Content -Path "$env:RUNNER_TEMP\installer-check.json" -Value '{"bundle":{"createUpdaterArtifacts":false}}' -Encoding utf8
|
||||
|
||||
+28
-2
@@ -2,9 +2,35 @@
|
||||
|
||||
记录 Codex-X 的新功能、体验改进和问题修复。
|
||||
|
||||
## [Unreleased]
|
||||
## [v0.3.26] - 2026-10-09
|
||||
|
||||
- 优化 Windows 在线更新:改用当前用户安装,旧 MSI 自动迁移;更新过程显示具体阶段,遇到问题提供提示和日志,避免重复启动安装。
|
||||
- 安装版在线更新的主通道失败后,自动通过官方 GitHub API 获取更新清单和安装包,继续由原生更新器检查版本、平台和签名;不再因一次检查失败就静默改为仅手动下载。
|
||||
|
||||
- 供应商新增上游协议选择,本地路由可将 Chat Completions、Claude Messages 和 Gemini 原生请求/响应转换为 Codex 使用的 Responses,支持图片、工具调用及流式输出。
|
||||
- 模型获取和连接检测按所选协议发送认证与 Headers;从 CC Switch 导入时保留显式协议选择。
|
||||
- 配置检查显示文件、行列、参数和具体处理建议;切回官方账号后可安全修复旧会话缺失的供应商定义。
|
||||
- 供应商页面新增自定义 Headers 编辑,支持固定值和环境变量,并在对应行显示校验错误;可选择 Claude Code / Kilo Code 的 User-Agent 预设,填写提示按请求头用途区分。
|
||||
- 使用协议转换时,退出和更新保留本地路由与恢复记录,重开后恢复相同协议;关闭路由前提示切回原生 Responses/官方,避免无效直连。
|
||||
|
||||
## [v0.3.24] - 2026-10-01
|
||||
|
||||
- 会话管理新增工作文件夹侧栏、文件夹与会话置顶,并使用虚拟列表减少大量会话的渲染开销。
|
||||
- 会话日志改为流式读取和磁盘快照处理,大日志及超长单条记录也能同步;读取、写入或复查失败时明确报错并执行安全回滚,避免日志、数据库索引与同步状态不一致。
|
||||
- 修复文件夹路径大小写混淆导致显示错误会话、置顶联动和缓存串用的问题,按实际文件系统身份区分目录或合并同目录别名。
|
||||
- 置顶按 Codex 配置目录分别保存,文件夹暂时不可读或重建后按原始路径保留置顶。旧 PR 预览版的 v1 置顶缓存不自动迁移,需重新置顶。
|
||||
|
||||
- 修复 Windows 下会话索引原子替换被文件句柄阻止的问题。
|
||||
|
||||
## [v0.3.22] - 2026-09-30
|
||||
|
||||
- 修复第三方模型被错误识别为“仅文本”而无法输入图片的问题,并自动升级旧版生成的模型目录。需供应商模型本身支持图片,更新菜单后重启 Codex 生效。
|
||||
- 供应商卡片支持上下拖动排序并自动保存,重启后保留顺序;也可聚焦左侧手柄后使用上下箭头移动。
|
||||
- 在供应商卡片显示已选模型名称,设置映射别名时同时保留真实模型 ID。
|
||||
- 补充 Ultra 思考强度的 Codex 设置提示,并修复旧模型目录遗漏图片或完整思考强度的问题。
|
||||
|
||||
## [v0.3.21] - 2026-09-23
|
||||
|
||||
- 优化 Windows 在线更新,缩短安装等待;旧版可自动迁移,正常更新无需重启电脑,账号和配置保留。
|
||||
|
||||
## [v0.3.20] - 2026-09-18
|
||||
|
||||
|
||||
@@ -238,7 +238,8 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
- 保存多个可命名的官方 Codex 登录配置与第三方供应商,随时查看当前正在使用哪一个
|
||||
- 点击“复制”直接新增独立副本,无需确认或进入编辑页;名称可稍后修改
|
||||
- 切换前可检测连接,并可获取模型进行测试
|
||||
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
|
||||
- 在同一页面编辑 Base URL、API Key、Model、上游接口协议、Headers 和完整 TOML
|
||||
- 本地路由支持 Responses、Chat Completions、Claude Messages、Gemini 原生接口,使用方式与边界见[供应商协议说明](docs/PROVIDER_PROTOCOLS.md)
|
||||
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
|
||||
- 切回 OpenAI Official 时保留当前官方登录态,第三方配置也不会凭空消失
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.26",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+2
-1
@@ -492,7 +492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.20"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -519,6 +519,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml_edit 0.22.27",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip 2.4.2",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.20"
|
||||
version = "0.3.26"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -46,3 +46,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }
|
||||
flate2 = "1"
|
||||
zstd = "0.13"
|
||||
tempfile = "3.27"
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2.8.2", features = ["test"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 7;
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 9;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
@@ -149,6 +149,7 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
upstream_api TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -228,6 +229,11 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
order_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -255,6 +261,12 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
"model_mappings_json",
|
||||
"ALTER TABLE providers ADD COLUMN model_mappings_json TEXT NOT NULL DEFAULT '[]'",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"upstream_api",
|
||||
"ALTER TABLE providers ADD COLUMN upstream_api TEXT",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
|
||||
ON providers(source, source_id)
|
||||
@@ -434,6 +446,180 @@ mod tests {
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_seven_database_adds_card_order_preferences_without_changing_provider_data() {
|
||||
let path = test_db_path("provider-card-order-v8");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let legacy = initializer.open_at(&path).unwrap();
|
||||
legacy.execute_batch(
|
||||
"INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, created_at, updated_at)
|
||||
VALUES ('existing', 'Existing provider', 'https://fixture.test/v1', 'fixture-model',
|
||||
'fixture-key', 'original-created', 'original-updated');
|
||||
INSERT INTO official_profiles
|
||||
(codex_dir, id, provider_name, created_at, updated_at)
|
||||
VALUES ('/fixture/config', 'existing-account', 'Existing account', 'created', 'updated');
|
||||
DROP TABLE provider_card_orders;
|
||||
PRAGMA user_version = 7;",
|
||||
)
|
||||
.unwrap();
|
||||
let before = crate::providers::list_saved_providers_on_connection(&legacy).unwrap();
|
||||
drop(legacy);
|
||||
|
||||
let migrated = initializer.open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
crate::providers::list_saved_providers_on_connection(&migrated).unwrap(),
|
||||
before
|
||||
);
|
||||
let timestamps: (String, String) = migrated
|
||||
.query_row(
|
||||
"SELECT created_at, updated_at FROM providers WHERE id = 'existing'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timestamps,
|
||||
("original-created".into(), "original-updated".into())
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT provider_name FROM official_profiles WHERE id = 'existing-account'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"Existing account"
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row("SELECT COUNT(*) FROM provider_card_orders", [], |row| row
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_eight_migration_keeps_provider_data_and_defaults_upstream_api_to_null() {
|
||||
let path = test_db_path("provider-upstream-api-v9");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let legacy = Connection::open(&path).unwrap();
|
||||
legacy
|
||||
.execute_batch(
|
||||
"CREATE TABLE providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, toml_config, wire_api,
|
||||
requires_openai_auth, model_mappings_json, source, source_id,
|
||||
created_at, updated_at)
|
||||
VALUES
|
||||
('legacy-responses', 'Existing Responses provider', 'https://responses.fixture/v1',
|
||||
'existing-model', 'fixture-secret', 'model_provider = \"custom\"', 'responses',
|
||||
1, '[{\"displayName\":\"Mapped model\",\"model\":\"upstream-model\"}]',
|
||||
'cc-switch', 'fixture-source-row', 'original-created', 'original-updated'),
|
||||
('legacy-chat', 'Existing Chat provider', 'https://chat.fixture/v1',
|
||||
'chat-model', NULL, NULL, 'chat',
|
||||
0, '[]', 'manual', NULL, 'chat-created', 'chat-updated');
|
||||
PRAGMA user_version = 8;",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(schema_version(&legacy).unwrap(), 8);
|
||||
assert!(!table_column_set(&legacy, "providers")
|
||||
.unwrap()
|
||||
.contains("upstream_api"));
|
||||
let snapshot = |conn: &Connection| {
|
||||
let mut statement = conn
|
||||
.prepare(
|
||||
"SELECT id, provider_name, base_url, model, api_key, toml_config,
|
||||
wire_api, requires_openai_auth, model_mappings_json,
|
||||
source, source_id, created_at, updated_at
|
||||
FROM providers ORDER BY id",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
(0..13)
|
||||
.map(|index| row.get::<_, rusqlite::types::Value>(index))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap();
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().unwrap()
|
||||
};
|
||||
let before = snapshot(&legacy);
|
||||
drop(legacy);
|
||||
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let migrated = initializer.open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), 9);
|
||||
assert!(table_column_set(&migrated, "providers")
|
||||
.unwrap()
|
||||
.contains("upstream_api"));
|
||||
assert_eq!(
|
||||
snapshot(&migrated),
|
||||
before,
|
||||
"all pre-existing provider columns must survive migration unchanged"
|
||||
);
|
||||
let upstream_values = migrated
|
||||
.prepare("SELECT upstream_api FROM providers ORDER BY id")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, Option<String>>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(upstream_values, vec![None, None]);
|
||||
let providers = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
let responses = providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == "legacy-responses")
|
||||
.unwrap();
|
||||
assert!(responses.upstream_api.is_none());
|
||||
assert_eq!(responses.api_key.as_deref(), Some("fixture-secret"));
|
||||
assert_eq!(responses.model_mappings.len(), 1);
|
||||
assert_eq!(responses.model_mappings[0].model, "upstream-model");
|
||||
drop(migrated);
|
||||
|
||||
let reopened = DatabaseInitializer::new().open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&reopened).unwrap(), 9);
|
||||
assert_eq!(
|
||||
snapshot(&reopened),
|
||||
before,
|
||||
"reopening must leave migrated provider data unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM providers WHERE upstream_api IS NULL",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
drop(reopened);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
|
||||
let path = test_db_path("skills-mcp-notes-migration");
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Serialize;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{ipc::Channel, Manager, ResourceId, Webview};
|
||||
@@ -17,8 +17,32 @@ use tauri_plugin_updater::Update;
|
||||
mod windows;
|
||||
|
||||
static UPDATING: AtomicBool = AtomicBool::new(false);
|
||||
// 0: not installing, 1: installer preparation/write, 2: Windows handoff.
|
||||
static INSTALLATION_PHASE: AtomicU8 = AtomicU8::new(0);
|
||||
const DOWNLOAD_TIMEOUT_MS: u64 = 10 * 60 * 1000;
|
||||
|
||||
struct InstallationExitGuard<'a>(&'a AtomicU8);
|
||||
impl<'a> InstallationExitGuard<'a> {
|
||||
fn begin(phase: &'a AtomicU8) -> Self {
|
||||
phase.store(1, Ordering::Release);
|
||||
Self(phase)
|
||||
}
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn handed_off(self) {
|
||||
self.0.store(2, Ordering::Release);
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
impl Drop for InstallationExitGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(0, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn defer_exit_during_installation() -> bool {
|
||||
INSTALLATION_PHASE.load(Ordering::Acquire) == 1
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub(crate) enum AppUpdateEvent {
|
||||
@@ -95,6 +119,24 @@ fn failure(stage: FailureStage, message: &str, log: Option<&UpdateLog>) -> Updat
|
||||
}
|
||||
}
|
||||
|
||||
/// Check before changing the installed application on every platform. The
|
||||
/// converted supplier cannot resume through a raw Responses endpoint after exit.
|
||||
fn install_with_route_preflight<T>(
|
||||
check: impl FnOnce() -> Result<(), ()>,
|
||||
install: impl FnOnce() -> Result<T, UpdateFailure>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<T, UpdateFailure> {
|
||||
if check().is_err() {
|
||||
log.record("routing_recovery_preflight_failed");
|
||||
return Err(failure(
|
||||
FailureStage::Prepare,
|
||||
"更新前未能确认连接配置可恢复,安装尚未开始。请检查路由配置后重试。",
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
install()
|
||||
}
|
||||
|
||||
/// This log contains only our fixed stage names and version, never URLs,
|
||||
/// headers, auth/config contents or raw network/parser error messages.
|
||||
struct UpdateLog {
|
||||
@@ -142,6 +184,115 @@ fn timeout_ms(value: Option<u64>) -> u64 {
|
||||
.clamp(30_000, DOWNLOAD_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
enum DownloadAttemptFailure {
|
||||
Transport,
|
||||
Verification,
|
||||
}
|
||||
|
||||
async fn download_attempt(
|
||||
update: &Update,
|
||||
on_event: &Channel<AppUpdateEvent>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<Vec<u8>, DownloadAttemptFailure> {
|
||||
let finished = AtomicBool::new(false);
|
||||
let mut started = false;
|
||||
update
|
||||
.download(
|
||||
|chunk_length, content_length| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = on_event.send(AppUpdateEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(AppUpdateEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
finished.store(true, Ordering::Release);
|
||||
log.record("verifying_signature");
|
||||
let _ = on_event.send(AppUpdateEvent::Verifying);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if finished.load(Ordering::Acquire) {
|
||||
DownloadAttemptFailure::Verification
|
||||
} else {
|
||||
DownloadAttemptFailure::Transport
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_with_recovery(
|
||||
update: &Update,
|
||||
mut candidates: Vec<reqwest::Url>,
|
||||
on_event: &Channel<AppUpdateEvent>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<Vec<u8>, UpdateFailure> {
|
||||
match download_attempt(update, on_event, log).await {
|
||||
Ok(bytes) => return Ok(bytes),
|
||||
Err(DownloadAttemptFailure::Verification) => {
|
||||
log.record("signature_verification_failed");
|
||||
return Err(failure(
|
||||
FailureStage::Verify,
|
||||
"更新包校验未通过,未启动安装。请重新检查在线更新。",
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
Err(DownloadAttemptFailure::Transport) => log.record("primary_download_transport_failed"),
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
match crate::app_update_channel::discover_download_candidates(update).await {
|
||||
Ok(found) => candidates = found,
|
||||
Err(_) => log.record("official_api_asset_lookup_failed"),
|
||||
}
|
||||
}
|
||||
for candidate in candidates {
|
||||
let alternate = match crate::app_update_channel::api_download_update(update, &candidate) {
|
||||
Ok(alternate) => alternate,
|
||||
Err(_) => continue,
|
||||
};
|
||||
log.record("retrying_download_via_official_api");
|
||||
match download_attempt(&alternate, on_event, log).await {
|
||||
Ok(bytes) => {
|
||||
log.record("official_api_download_verified");
|
||||
return Ok(bytes);
|
||||
}
|
||||
Err(DownloadAttemptFailure::Verification) => {
|
||||
log.record("signature_verification_failed");
|
||||
return Err(failure(
|
||||
FailureStage::Verify,
|
||||
"更新包校验未通过,未启动安装。请重新检查在线更新。",
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
Err(DownloadAttemptFailure::Transport) => {
|
||||
log.record("official_api_download_transport_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(failure(
|
||||
FailureStage::Download,
|
||||
"在线更新的主通道和官方备用通道均未能完成下载。请检查网络或代理后重试。",
|
||||
Some(log),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn verify_download_recovery_for_test(
|
||||
update: &Update,
|
||||
candidates: Vec<reqwest::Url>,
|
||||
) -> Result<Vec<u8>, UpdateFailure> {
|
||||
let temporary = tempfile::tempdir().expect("temporary updater test log");
|
||||
let log = UpdateLog::create(temporary.path(), &update.version).expect("updater test log");
|
||||
let channel = Channel::new(|_| Ok(()));
|
||||
let bytes = download_with_recovery(update, candidates, &channel, &log).await?;
|
||||
// Exercise the Windows staging handoff with verified fixture bytes only.
|
||||
// Dropping this private temporary file never executes an installer.
|
||||
let staged = windows::StagedInstaller::create(&bytes)
|
||||
.expect("verified synthetic Windows packet stages successfully");
|
||||
drop(staged);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// No installer is launched until all preparation succeeds. If launch fails,
|
||||
/// restore routing while the original app and single-instance lock still live.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
@@ -201,18 +352,26 @@ pub(crate) async fn install_app_update(
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
let _lease = UpdateLease::acquire(&UPDATING)?;
|
||||
let update = webview
|
||||
.resources_table()
|
||||
.get::<Update>(update_rid)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"更新信息已失效,请重新检查更新。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let mut update = (*update).clone();
|
||||
// The 15s metadata-check timeout must not become the package-download limit.
|
||||
let (mut update, download_candidates) = if let Ok(owned) =
|
||||
webview
|
||||
.resources_table()
|
||||
.get::<crate::app_update_channel::OwnedOnlineUpdate>(update_rid)
|
||||
{
|
||||
(owned.update.clone(), owned.download_candidates.clone())
|
||||
} else {
|
||||
let update = webview
|
||||
.resources_table()
|
||||
.get::<Update>(update_rid)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"更新信息已失效,请重新检查更新。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
((*update).clone(), Vec::new())
|
||||
};
|
||||
// The metadata-check timeout must not become the package-download limit.
|
||||
update.timeout = Some(Duration::from_millis(timeout_ms(timeout)));
|
||||
if let Some(headers) = headers {
|
||||
update.headers.clear();
|
||||
@@ -235,47 +394,14 @@ pub(crate) async fn install_app_update(
|
||||
)
|
||||
})?;
|
||||
log.record("download_started");
|
||||
let finished = AtomicBool::new(false);
|
||||
let mut started = false;
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk_length, content_length| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = on_event.send(AppUpdateEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(AppUpdateEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
finished.store(true, Ordering::Release);
|
||||
log.record("verifying_signature");
|
||||
let _ = on_event.send(AppUpdateEvent::Verifying);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if finished.load(Ordering::Acquire) {
|
||||
log.record("signature_verification_failed");
|
||||
failure(
|
||||
FailureStage::Verify,
|
||||
"更新包校验未通过,安装尚未启动。请重新下载或前往下载页。",
|
||||
Some(&log),
|
||||
)
|
||||
} else {
|
||||
log.record("download_failed");
|
||||
failure(
|
||||
FailureStage::Download,
|
||||
"更新下载未完成或连接超时。请检查网络后重试。",
|
||||
Some(&log),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
let bytes = download_with_recovery(&update, download_candidates, &on_event, &log).await?;
|
||||
log.record("signature_verified");
|
||||
let exit_guard = InstallationExitGuard::begin(&INSTALLATION_PHASE);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app = webview.app_handle().clone();
|
||||
let result = install_windows(app, bytes, on_event, log).await;
|
||||
let result = install_windows(app, bytes, on_event, log, exit_guard).await;
|
||||
if result.is_ok() {
|
||||
_lease.handoff();
|
||||
}
|
||||
@@ -283,17 +409,33 @@ pub(crate) async fn install_app_update(
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("install_started");
|
||||
let _ = on_event.send(AppUpdateEvent::Preparing);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
update.install(bytes).map_err(|_| {
|
||||
log.record("install_failed");
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重试或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})?;
|
||||
let _exit_guard = exit_guard;
|
||||
install_with_route_preflight(
|
||||
|| crate::failover::begin_update_handoff().map_err(|_| ()),
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("install_started");
|
||||
update.install(bytes).map_err(|_| {
|
||||
log.record("install_failed");
|
||||
if crate::failover::resume_after_failed_update().is_err() {
|
||||
log.record("routing_resume_failed");
|
||||
}
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重试或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})
|
||||
},
|
||||
&log,
|
||||
)?;
|
||||
// The application bytes are already installed. A recovery warning
|
||||
// must not turn this into an install retry or a second installer.
|
||||
if crate::failover::resume_after_failed_update().is_err() {
|
||||
log.record("routing_resume_failed_after_install_restart_required");
|
||||
}
|
||||
log.record("install_finished_restart_required");
|
||||
Ok(InstallResult {
|
||||
restart_required: true,
|
||||
@@ -301,6 +443,7 @@ pub(crate) async fn install_app_update(
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
let _ = crate::failover::resume_after_failed_update();
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重新打开软件后检查更新。",
|
||||
@@ -319,6 +462,7 @@ async fn install_windows(
|
||||
bytes: Vec<u8>,
|
||||
on_event: Channel<AppUpdateEvent>,
|
||||
log: UpdateLog,
|
||||
exit_guard: InstallationExitGuard<'static>,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Stage bytes before changing live routes. Only signed .exe payloads
|
||||
@@ -332,20 +476,27 @@ async fn install_windows(
|
||||
})?;
|
||||
let _ = on_event.send(AppUpdateEvent::Preparing);
|
||||
log.record("preparing_route_shutdown");
|
||||
prepared_install(
|
||||
|| crate::failover::shutdown_all().map_err(|_| ()),
|
||||
install_with_route_preflight(
|
||||
|| crate::failover::ensure_shutdown_allowed().map_err(|_| ()),
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("launching_installer");
|
||||
installer.launch(std::process::id()).map_err(|_| ())
|
||||
prepared_install(
|
||||
|| crate::failover::shutdown_all().map_err(|_| ()),
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("launching_installer");
|
||||
installer.launch(std::process::id()).map_err(|_| ())
|
||||
},
|
||||
|| crate::failover::resume_after_failed_update().map_err(|_| ()),
|
||||
&log,
|
||||
)
|
||||
},
|
||||
|| crate::failover::resume_after_failed_update().map_err(|_| ()),
|
||||
&log,
|
||||
)?;
|
||||
log.record("installer_handed_off");
|
||||
let _ = on_event.send(AppUpdateEvent::HandedOff);
|
||||
// Normal Tauri exit destroys windows/tray/single-instance lock. The
|
||||
// new installer waits for this PID before touching installed files.
|
||||
exit_guard.handed_off();
|
||||
app.exit(0);
|
||||
Ok(InstallResult {
|
||||
restart_required: false,
|
||||
@@ -366,6 +517,69 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[test]
|
||||
fn installation_exit_guard_releases_on_failure_and_allows_windows_handoff() {
|
||||
let phase = AtomicU8::new(0);
|
||||
{
|
||||
let _guard = InstallationExitGuard::begin(&phase);
|
||||
assert_eq!(phase.load(Ordering::Acquire), 1);
|
||||
}
|
||||
assert_eq!(phase.load(Ordering::Acquire), 0);
|
||||
InstallationExitGuard::begin(&phase).handed_off();
|
||||
assert_eq!(phase.load(Ordering::Acquire), 2);
|
||||
assert_ne!(phase.load(Ordering::Acquire), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_recovery_preflight_blocks_install_before_application_bytes_change() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let app = temp.path().join("fixture-app");
|
||||
fs::write(&app, b"original fixture").unwrap();
|
||||
let error = install_with_route_preflight(
|
||||
|| Err(()),
|
||||
|| {
|
||||
fs::write(&app, b"replacement fixture").unwrap();
|
||||
Ok(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.stage, FailureStage::Prepare);
|
||||
assert!(error.message.contains("可恢复"));
|
||||
assert!(error.message.contains("安装尚未开始"));
|
||||
assert_eq!(fs::read(&app).unwrap(), b"original fixture");
|
||||
let stages = fs::read_to_string(&log.path).unwrap();
|
||||
assert!(stages.contains("routing_recovery_preflight_failed"));
|
||||
assert!(!stages.contains("install_started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_route_preflight_installs_once_and_preserves_installer_errors() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let calls = RefCell::new(vec![]);
|
||||
let error = install_with_route_preflight(
|
||||
|| {
|
||||
calls.borrow_mut().push("check");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("install");
|
||||
Err::<(), _>(failure(
|
||||
FailureStage::Install,
|
||||
"fixture install error",
|
||||
Some(&log),
|
||||
))
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(*calls.borrow(), vec!["check", "install"]);
|
||||
assert_eq!(error.stage, FailureStage::Install);
|
||||
assert_eq!(error.message, "fixture install error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_update_at_a_time_and_errors_release_the_guard() {
|
||||
let flag = AtomicBool::new(false);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use toml_edit::{value, DocumentMut, Item, TableLike};
|
||||
use toml_edit::{value, DocumentMut, ImDocument, Item, Key, Table, TableLike};
|
||||
|
||||
const MAX_CONFIG_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const BUILTIN_PROVIDERS: &[&str] = &[
|
||||
@@ -31,6 +31,11 @@ pub(crate) struct ConfigHealthIssue {
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) repairable: bool,
|
||||
pub(crate) path: String,
|
||||
pub(crate) line: Option<usize>,
|
||||
pub(crate) column: Option<usize>,
|
||||
pub(crate) key: String,
|
||||
pub(crate) suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -277,18 +282,35 @@ fn fingerprint(
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
fn key_path(parts: &[&str]) -> String {
|
||||
parts
|
||||
.iter()
|
||||
.map(|part| Key::new(*part).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
fn add_issue(
|
||||
report: &mut ConfigHealthReport,
|
||||
code: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
repair: Option<&str>,
|
||||
key: &[&str],
|
||||
suggestion: &str,
|
||||
) {
|
||||
report.issues.push(ConfigHealthIssue {
|
||||
code: code.to_string(),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
repairable: repair.is_some(),
|
||||
path: config_path(Path::new(&report.codex_dir))
|
||||
.display()
|
||||
.to_string(),
|
||||
line: None,
|
||||
column: None,
|
||||
key: key_path(key),
|
||||
suggestion: suggestion.to_string(),
|
||||
});
|
||||
if let Some(summary) = repair {
|
||||
if !report
|
||||
@@ -301,6 +323,136 @@ fn add_issue(
|
||||
}
|
||||
}
|
||||
|
||||
fn line_column(text: &str, offset: usize) -> (usize, usize) {
|
||||
let mut offset = offset.min(text.len());
|
||||
while !text.is_char_boundary(offset) {
|
||||
offset -= 1;
|
||||
}
|
||||
let prefix = &text[..offset];
|
||||
(
|
||||
prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
|
||||
prefix
|
||||
.rsplit('\n')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.count()
|
||||
+ 1,
|
||||
)
|
||||
}
|
||||
|
||||
fn locate_issues(report: &mut ConfigHealthReport, source: &ImDocument<&str>) {
|
||||
for issue in &mut report.issues {
|
||||
let Ok(keys) = Key::parse(&issue.key) else {
|
||||
continue;
|
||||
};
|
||||
let mut item = source.as_item();
|
||||
let mut offset = None;
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
let Some(table) = item.as_table_like() else {
|
||||
offset = None;
|
||||
break;
|
||||
};
|
||||
let Some(next) = table.get(key.get()) else {
|
||||
offset = None;
|
||||
break;
|
||||
};
|
||||
if index + 1 == keys.len() {
|
||||
offset = table
|
||||
.key(key.get())
|
||||
.and_then(Key::span)
|
||||
.map(|span| span.start)
|
||||
.or_else(|| next.span().map(|span| span.start));
|
||||
}
|
||||
item = next;
|
||||
}
|
||||
if let Some(offset) = offset {
|
||||
let (line, column) = line_column(source.raw(), offset);
|
||||
issue.line = Some(line);
|
||||
issue.column = Some(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recover only TOML key names, never values or the parser's source-line display.
|
||||
// An invalid key/header is identified as document syntax rather than guessed.
|
||||
fn delimiter_outside_quotes(text: &str, delimiter: char) -> Option<usize> {
|
||||
let mut quote = None;
|
||||
let mut escaped = false;
|
||||
for (offset, character) in text.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if quote == Some('"') && character == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if quote == Some(character) {
|
||||
quote = None;
|
||||
continue;
|
||||
}
|
||||
if quote.is_some() {
|
||||
continue;
|
||||
}
|
||||
if character == '\'' || character == '"' {
|
||||
quote = Some(character);
|
||||
} else if character == delimiter {
|
||||
return Some(offset);
|
||||
} else if character == '#' {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn syntax_key(text: &str, offset: usize) -> Vec<String> {
|
||||
let error_line = line_column(text, offset).0;
|
||||
let mut section: Vec<String> = Vec::new();
|
||||
let mut current = vec!["<TOML 文档结构>".to_string()];
|
||||
for line in text.lines().take(error_line) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('[') {
|
||||
let body = line.trim_start_matches('[');
|
||||
let candidate = delimiter_outside_quotes(body, ']').map_or(body, |end| &body[..end]);
|
||||
if let Ok(keys) = Key::parse(candidate) {
|
||||
section = keys.iter().map(|key| key.get().to_string()).collect();
|
||||
current = section.clone();
|
||||
} else {
|
||||
current = vec!["<TOML 表头>".to_string()];
|
||||
}
|
||||
} else if let Some(equal) = delimiter_outside_quotes(line, '=') {
|
||||
if let Ok(keys) = Key::parse(line[..equal].trim()) {
|
||||
current = section
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(keys.iter().map(|key| key.get().to_string()))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
fn syntax_suggestion(message: &str) -> &'static str {
|
||||
if message.contains("duplicate") {
|
||||
"同一配置范围内的参数或表重复定义;保留一处正确定义,合并需要保留的内容后重新检查。"
|
||||
} else if message.contains("string") || message.contains("quote") {
|
||||
"检查此参数的引号是否成对;单行字符串不能直接换行,需换行时使用 TOML 三引号字符串。"
|
||||
} else if message.contains("array") || message.contains("expected `]`") {
|
||||
"检查此参数的数组是否以 ] 结束,各元素之间是否有逗号;修正后重新检查。"
|
||||
} else if message.contains("table") || message.contains("expected `.`") {
|
||||
"检查表头的方括号与点分参数名;例如 [model_providers.custom],同一张表不能重复定义。"
|
||||
} else if message.contains("expected `=`") {
|
||||
"参数必须写成 参数名 = 值;补齐等号并使用 TOML 支持的值格式后重新检查。"
|
||||
} else {
|
||||
"检查标记位置的引号、逗号和括号是否配对,参数应写成 参数名 = 值;修正后重新检查。"
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_replacement(item: &Item) -> Option<bool> {
|
||||
match item.as_str()?.trim().to_ascii_lowercase().as_str() {
|
||||
"true" => Some(true),
|
||||
@@ -336,8 +488,10 @@ fn check_provider_table(
|
||||
report,
|
||||
"provider-table-type",
|
||||
"供应商配置格式不正确",
|
||||
"有一条供应商配置不是有效的配置表,请在供应商页面重新检查配置。",
|
||||
"此供应商的定义不是 TOML 配置表。",
|
||||
None,
|
||||
&["model_providers", id],
|
||||
"将此项改为 [model_providers.供应商标识] 配置表,并恢复原有参数;现有值无法确定,需手动确认。",
|
||||
);
|
||||
return;
|
||||
};
|
||||
@@ -355,8 +509,10 @@ fn check_provider_table(
|
||||
report,
|
||||
"provider-name-missing",
|
||||
"供应商配置缺少名称",
|
||||
"有一条供应商配置没有填写名称,可使用它现有的标识补齐显示名称。",
|
||||
"此供应商的 name 缺失或为空。",
|
||||
(!id.trim().is_empty()).then_some("根据供应商标识补齐缺失的显示名称"),
|
||||
&["model_providers", id, "name"],
|
||||
&format!("补齐 name = {},保留供应商标识与其他参数。", value(id)),
|
||||
);
|
||||
}
|
||||
for key in ["name", "base_url"] {
|
||||
@@ -365,8 +521,14 @@ fn check_provider_table(
|
||||
report,
|
||||
"provider-text-type",
|
||||
"供应商文字配置格式不正确",
|
||||
"供应商名称或接口地址应为文字,暂时无法自动判断正确内容。",
|
||||
&format!("{key} 应为 TOML 字符串,现有值的类型不正确。"),
|
||||
None,
|
||||
&["model_providers", id, key],
|
||||
if key == "name" {
|
||||
"将 name 改为带引号的供应商显示名称,例如 name = \"My API\"。"
|
||||
} else {
|
||||
"将 base_url 改为带引号的接口地址,例如 base_url = \"https://供应商地址/v1\";地址需向供应商确认。"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -386,16 +548,20 @@ fn check_provider_table(
|
||||
report,
|
||||
"provider-boolean-text",
|
||||
"供应商开关格式不正确",
|
||||
"有一项开关被写成了文字,可能导致 Codex 无法加载配置。",
|
||||
&format!("{key} 被写成了字符串,Codex 需要布尔值。"),
|
||||
Some("将写成文字的开关恢复为正确格式"),
|
||||
&["model_providers", id, key],
|
||||
&format!("将 {key} 改为 {replacement}(不带引号),保留当前开关含义。"),
|
||||
);
|
||||
} else {
|
||||
add_issue(
|
||||
report,
|
||||
"provider-boolean-type",
|
||||
"供应商开关无法识别",
|
||||
"有一项开关不是有效的开启或关闭值,需要手动确认。",
|
||||
&format!("{key} 不是布尔值,也无法确定其开启或关闭含义。"),
|
||||
None,
|
||||
&["model_providers", id, key],
|
||||
&format!("确认所需状态后将 {key} 写为 true 或 false(不带引号)。"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -420,6 +586,8 @@ fn check_provider_table(
|
||||
"DeepSeek 的连接方式需要调整",
|
||||
"DeepSeek 官方接口不支持当前开启的 WebSocket 连接,可能先报错并多次重试,再正常回复。",
|
||||
Some("将 DeepSeek 官方接口改用 HTTP 连接,避免发送消息时反复重试"),
|
||||
&["model_providers", id, "supports_websockets"],
|
||||
"将 supports_websockets 改为 false,让 DeepSeek 官方接口使用 HTTP。",
|
||||
);
|
||||
}
|
||||
if let Some(item) = table.get_mut("wire_api") {
|
||||
@@ -439,6 +607,8 @@ fn check_provider_table(
|
||||
"供应商接口格式拼写不正确",
|
||||
"接口格式中存在多余空格或大小写错误,可能导致 Codex 无法加载配置。",
|
||||
Some("修正接口格式的空格或大小写"),
|
||||
&["model_providers", id, "wire_api"],
|
||||
"将 wire_api 规范为 \"responses\",去掉多余空格并使用小写。",
|
||||
);
|
||||
} else {
|
||||
add_issue(
|
||||
@@ -447,6 +617,8 @@ fn check_provider_table(
|
||||
"供应商接口格式不受支持",
|
||||
"当前 Codex 需要 Responses 接口。请向供应商确认支持的接口,或使用兼容的转接服务。",
|
||||
None,
|
||||
&["model_providers", id, "wire_api"],
|
||||
"先向供应商确认支持 Responses API;支持时设置 wire_api = \"responses\",否则选择兼容供应商。不能仅改名称来转换协议。",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -462,6 +634,8 @@ fn check_provider_table(
|
||||
"官方登录的认证设置不完整",
|
||||
"已发现官方登录信息,但这条官方供应商配置没有启用登录认证。",
|
||||
Some("为官方登录补齐认证开关"),
|
||||
&["model_providers", id, "requires_openai_auth"],
|
||||
"设置 requires_openai_auth = true,使用已确认的 ChatGPT 登录信息。",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -487,6 +661,64 @@ fn complete_alias_candidate(item: &Item) -> bool {
|
||||
|| table.get("requires_openai_auth").and_then(Item::as_bool) == Some(true))
|
||||
}
|
||||
|
||||
// A historical provider ID may be routed to the user's explicitly selected
|
||||
// official login only when neither root nor provider fields select other auth
|
||||
// or endpoints. No saved accounts, backups or credentials are borrowed.
|
||||
fn official_history_alias(doc: &DocumentMut, chatgpt_auth: bool) -> Option<Item> {
|
||||
if !chatgpt_auth
|
||||
|| [
|
||||
"base_url",
|
||||
"experimental_bearer_token",
|
||||
"auth",
|
||||
"env_key",
|
||||
"env_key_instructions",
|
||||
"http_headers",
|
||||
"env_http_headers",
|
||||
"query_params",
|
||||
"api_base",
|
||||
"chatgpt_base_url",
|
||||
"api_key",
|
||||
"openai_api_key",
|
||||
"auth_mode",
|
||||
"tokens",
|
||||
"env_vars",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| doc.contains_key(key))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let providers = match doc.get("model_providers") {
|
||||
None => None,
|
||||
Some(item) => Some(item.as_table_like()?),
|
||||
};
|
||||
let selected = match doc.get("model_provider") {
|
||||
Some(item) => item.as_str()?,
|
||||
None => "openai",
|
||||
};
|
||||
if doc
|
||||
.get("forced_login_method")
|
||||
.is_some_and(|item| item.as_str() != Some("chatgpt"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(item) = providers.and_then(|providers| providers.get(selected)) {
|
||||
let table = item.as_table_like()?;
|
||||
return (is_official_login_table(table, chatgpt_auth)
|
||||
&& !table.contains_key("query_params")
|
||||
&& complete_alias_candidate(item))
|
||||
.then(|| item.clone());
|
||||
}
|
||||
if selected != "openai" {
|
||||
return None;
|
||||
}
|
||||
let mut table = Table::new();
|
||||
table.insert("name", value("OpenAI"));
|
||||
table.insert("requires_openai_auth", value(true));
|
||||
table.insert("wire_api", value("responses"));
|
||||
Some(Item::Table(table))
|
||||
}
|
||||
|
||||
fn collect_provider_reference(
|
||||
item: Option<&Item>,
|
||||
references: &mut Vec<String>,
|
||||
@@ -500,6 +732,8 @@ fn collect_provider_reference(
|
||||
"选用的供应商设置不正确",
|
||||
"供应商标识应为非空文字,请在供应商页面重新选择后保存。",
|
||||
None,
|
||||
&["model_provider"],
|
||||
"将 model_provider 设置为已定义的非空供应商标识,例如 \"openai\" 或 [model_providers.custom] 对应的 \"custom\";需先确认所选供应商。",
|
||||
);
|
||||
return;
|
||||
};
|
||||
@@ -538,6 +772,8 @@ fn analyze(
|
||||
"暂时无法检查配置",
|
||||
message,
|
||||
None,
|
||||
&["<配置文件>"],
|
||||
"检查显示路径对应文件的存在性、文件类型、读取权限与大小后重试。",
|
||||
);
|
||||
return (report, None);
|
||||
}
|
||||
@@ -549,45 +785,36 @@ fn analyze(
|
||||
&mut report,
|
||||
"config-encoding",
|
||||
"配置文件的文字编码不正确",
|
||||
"配置文件需要使用 UTF-8 编码,请手动检查后保存。",
|
||||
"配置文件需要使用 UTF-8 编码。",
|
||||
None,
|
||||
&["<文件编码>"],
|
||||
"在文本编辑器中按原编码打开此文件,再以 UTF-8 编码保存;编码无法可靠判断,不能自动转换。",
|
||||
);
|
||||
return (report, None);
|
||||
};
|
||||
let mut doc = match text.parse::<DocumentMut>() {
|
||||
let source = match ImDocument::parse(text) {
|
||||
Ok(doc) => doc,
|
||||
Err(error) => {
|
||||
report.status = "issues";
|
||||
// TOML's normal Display embeds entire source lines, including credentials.
|
||||
let description = error
|
||||
.span()
|
||||
.map(|span| {
|
||||
let mut offset = span.start.min(text.len());
|
||||
while !text.is_char_boundary(offset) {
|
||||
offset -= 1;
|
||||
}
|
||||
let prefix = &text[..offset];
|
||||
let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
|
||||
let column = prefix
|
||||
.rsplit('\n')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.count()
|
||||
+ 1;
|
||||
format!("配置文件第 {line} 行、第 {column} 列附近有格式错误,需要手动检查。")
|
||||
})
|
||||
.unwrap_or_else(|| "配置文件中存在格式错误,需要手动检查。".to_string());
|
||||
let offset = error.span().map_or(0, |span| span.start);
|
||||
let (line, column) = line_column(text, offset);
|
||||
let keys = syntax_key(text, offset);
|
||||
let keys: Vec<_> = keys.iter().map(String::as_str).collect();
|
||||
add_issue(
|
||||
&mut report,
|
||||
"config-syntax",
|
||||
"配置文件格式不正确",
|
||||
&description,
|
||||
&format!("配置文件第 {line} 行、第 {column} 列附近存在 TOML 语法错误。"),
|
||||
None,
|
||||
&keys,
|
||||
syntax_suggestion(error.message()),
|
||||
);
|
||||
report.issues[0].line = Some(line);
|
||||
report.issues[0].column = Some(column);
|
||||
return (report, None);
|
||||
}
|
||||
};
|
||||
let mut doc = source.clone().into_mut();
|
||||
let mut references = Vec::new();
|
||||
collect_provider_reference(doc.get("model_provider"), &mut references, &mut report);
|
||||
// Profile loading changed between Codex versions. Leave legacy inline
|
||||
@@ -602,8 +829,10 @@ fn analyze(
|
||||
&mut report,
|
||||
"providers-table-type",
|
||||
"供应商列表格式不正确",
|
||||
"model_providers 应为配置表,暂时无法自动恢复原有供应商。",
|
||||
"model_providers 应为 TOML 配置表,现有值的类型不正确。",
|
||||
None,
|
||||
&["model_providers"],
|
||||
"恢复 [model_providers.供应商标识] 配置表与原有参数;现有内容无法可靠还原,需手动确认。",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -640,9 +869,27 @@ fn analyze(
|
||||
.filter(|id| !BUILTIN_PROVIDERS.contains(id))
|
||||
.and_then(|id| providers.and_then(|providers| providers.get(id)))
|
||||
.is_some_and(complete_alias_candidate);
|
||||
if missing.len() == 1 && candidates.len() == 1 && (!history_only || selected_custom_exists)
|
||||
{
|
||||
let source_name: String = candidates[0]
|
||||
let official_alias = history_only
|
||||
.then(|| official_history_alias(&doc, chatgpt_auth))
|
||||
.flatten();
|
||||
let selected_official_style = doc
|
||||
.get("model_provider")
|
||||
.and_then(Item::as_str)
|
||||
.and_then(|id| providers.and_then(|providers| providers.get(id)))
|
||||
.and_then(Item::as_table_like)
|
||||
.is_some_and(|table| {
|
||||
table.get("name").and_then(Item::as_str) == Some("OpenAI")
|
||||
&& !table.contains_key("base_url")
|
||||
});
|
||||
let source = official_alias.clone().or_else(|| {
|
||||
(missing.len() == 1
|
||||
&& candidates.len() == 1
|
||||
&& (!history_only || (selected_custom_exists && !selected_official_style)))
|
||||
.then(|| candidates[0].clone())
|
||||
});
|
||||
let first_routing_summary = report.repair_summary.len();
|
||||
if let Some(source) = source {
|
||||
let source_name: String = source
|
||||
.as_table_like()
|
||||
.and_then(|table| table.get("name"))
|
||||
.and_then(Item::as_str)
|
||||
@@ -651,38 +898,51 @@ fn analyze(
|
||||
.filter(|ch| !ch.is_control())
|
||||
.take(60)
|
||||
.collect();
|
||||
doc.get_mut("model_providers")
|
||||
.and_then(Item::as_table_like_mut)
|
||||
.expect("candidate implies table")
|
||||
.insert(&missing[0], candidates[0].clone());
|
||||
if history_only {
|
||||
let summary =
|
||||
format!("让受影响的旧会话沿用当前供应商「{source_name}」,并保留原会话");
|
||||
add_issue(&mut report, "session-provider-definition-missing", "旧会话使用的供应商缺少配置", &format!("有些旧会话仍引用已缺失的供应商。修复后,这些会话将沿用当前供应商「{source_name}」,原会话会保留。"), Some(&summary));
|
||||
} else {
|
||||
let summary = format!("使用现有供应商「{source_name}」的配置补齐缺失的对应关系");
|
||||
add_issue(&mut report, "provider-definition-missing", "选用的供应商缺少配置", &format!("供应商标识与现有配置不对应。将使用现有供应商「{source_name}」的配置补齐对应关系,并保留原配置。"), Some(&summary));
|
||||
if doc.get("model_providers").is_none() {
|
||||
doc.as_table_mut()
|
||||
.insert("model_providers", Item::Table(Table::new()));
|
||||
}
|
||||
// Keep routing decisions visible in the compact startup notice.
|
||||
if let Some(summary) = report.repair_summary.pop() {
|
||||
report.repair_summary.insert(0, summary);
|
||||
for id in &missing {
|
||||
doc.get_mut("model_providers")
|
||||
.and_then(Item::as_table_like_mut)
|
||||
.expect("safe source requires a valid provider table")
|
||||
.insert(id, source.clone());
|
||||
let summary = if history_only {
|
||||
format!("补齐 [model_providers.{}],让旧会话沿用当前供应商「{source_name}」,并保留原会话", Key::new(id.as_str()))
|
||||
} else {
|
||||
format!(
|
||||
"使用现有供应商「{source_name}」补齐 [model_providers.{}],并保留原配置",
|
||||
Key::new(id.as_str())
|
||||
)
|
||||
};
|
||||
let description = if history_only {
|
||||
format!("旧会话仍引用供应商标识 {},但 config.toml 中缺少对应的配置表。修复后,这些会话将沿用当前供应商「{source_name}」。", Key::new(id.as_str()))
|
||||
} else {
|
||||
format!(
|
||||
"model_provider 选中的供应商 {} 缺少对应的配置表。",
|
||||
Key::new(id.as_str())
|
||||
)
|
||||
};
|
||||
add_issue(&mut report,
|
||||
if history_only { "session-provider-definition-missing" } else { "provider-definition-missing" },
|
||||
if history_only { "旧会话使用的供应商缺少配置" } else { "选用的供应商缺少配置" },
|
||||
&description, Some(&summary), &["model_providers", id],
|
||||
&format!("新增 {},复制已确认的当前{}供应商「{source_name}」参数;保留 model_provider、原有表和会话数据库。",
|
||||
key_path(&["model_providers", id]), if official_alias.is_some() { "官方登录" } else { "" }));
|
||||
}
|
||||
// Keep all routing decisions before smaller value corrections.
|
||||
let routing = report.repair_summary.split_off(first_routing_summary);
|
||||
report.repair_summary.splice(0..0, routing);
|
||||
} else {
|
||||
add_issue(
|
||||
&mut report,
|
||||
if history_only {
|
||||
"session-provider-definition-missing"
|
||||
} else {
|
||||
"provider-definition-missing"
|
||||
},
|
||||
if history_only {
|
||||
"旧会话使用的供应商缺少配置"
|
||||
} else {
|
||||
"选用的供应商缺少配置"
|
||||
},
|
||||
"未找到唯一可确认的供应商配置,请在供应商页面重新选择并启用,或手动补齐原配置。",
|
||||
None,
|
||||
);
|
||||
for id in &missing {
|
||||
add_issue(&mut report,
|
||||
if history_only { "session-provider-definition-missing" } else { "provider-definition-missing" },
|
||||
if history_only { "旧会话使用的供应商缺少配置" } else { "选用的供应商缺少配置" },
|
||||
&format!("{} 引用了供应商标识 {},但 {} 未定义,且无法确认应使用哪个来源。",
|
||||
if history_only { "旧会话" } else { "model_provider" }, Key::new(id.as_str()), key_path(&["model_providers", id])),
|
||||
None, &["model_providers", id],
|
||||
&format!("手动补齐 [{}] 的原供应商参数;若希望旧会话改用官方,请先启用官方配置并完成 ChatGPT 登录后重新检查。多个中转来源不会自动合并。", key_path(&["model_providers", id])));
|
||||
}
|
||||
}
|
||||
}
|
||||
report.status = if report.issues.is_empty() {
|
||||
@@ -704,10 +964,13 @@ fn analyze(
|
||||
&mut report,
|
||||
"config-linked-file",
|
||||
"配置文件由链接指向其他位置",
|
||||
"请打开原配置文件进行修改,以保留已有的文件链接。",
|
||||
"此 config.toml 是文件链接,不能安全替换链接本身。",
|
||||
None,
|
||||
&["<文件链接>"],
|
||||
"使用「查看配置」打开链接指向的原文件,按上述参数建议修复后重新检查。",
|
||||
);
|
||||
}
|
||||
locate_issues(&mut report, &source);
|
||||
let replacement = report.can_repair.then(|| doc.to_string());
|
||||
(report, replacement)
|
||||
}
|
||||
@@ -1163,7 +1426,263 @@ mod tests {
|
||||
assert_eq!(report.status, "issues");
|
||||
assert!(json.contains("第 2 行"));
|
||||
assert!(!json.contains("super-private-secret"));
|
||||
assert!(!json.contains("experimental_bearer_token"));
|
||||
let issue = &report.issues[0];
|
||||
assert_eq!(issue.path, config_path(&fixture.0).display().to_string());
|
||||
assert_eq!(issue.line, Some(2));
|
||||
assert!(issue.column.is_some_and(|column| column > 0));
|
||||
assert_eq!(issue.key, "experimental_bearer_token");
|
||||
assert!(!issue.suggestion.is_empty());
|
||||
assert!(!issue.repairable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_locate_the_original_parameter_and_give_an_exact_fix() {
|
||||
let fixture = Fixture::new("# 注释\nmodel_provider='custom'\n[model_providers.custom]\nname='My API'\nbase_url='https://fixture.example/v1'\n supports_websockets = 'TRUE' # 保留\nwire_api = 'chat'\n");
|
||||
let report = fixture.check();
|
||||
let boolean = report
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.code == "provider-boolean-text")
|
||||
.unwrap();
|
||||
assert_eq!(boolean.path, config_path(&fixture.0).display().to_string());
|
||||
assert_eq!((boolean.line, boolean.column), (Some(6), Some(3)));
|
||||
assert_eq!(boolean.key, "model_providers.custom.supports_websockets");
|
||||
assert!(boolean.suggestion.contains("supports_websockets 改为 true"));
|
||||
let protocol = report
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.code == "provider-protocol-unsupported")
|
||||
.unwrap();
|
||||
assert_eq!((protocol.line, protocol.column), (Some(7), Some(1)));
|
||||
assert_eq!(protocol.key, "model_providers.custom.wire_api");
|
||||
assert!(protocol.suggestion.contains("Responses API"));
|
||||
assert!(!protocol.repairable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_inline_and_quoted_provider_keys_keep_precise_locations() {
|
||||
for (text, line, column) in [
|
||||
("model_providers.\"with.dot\".name=9\n", 1, 28),
|
||||
("model_providers = { \"with.dot\" = { name = 9 } }\n", 1, 36),
|
||||
("[model_providers.\"with.dot\"]\nname=9\n", 2, 1),
|
||||
] {
|
||||
let fixture = Fixture::new(text);
|
||||
let report = fixture.check();
|
||||
let issue = report
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.code == "provider-text-type")
|
||||
.unwrap();
|
||||
assert_eq!(issue.key, "model_providers.\"with.dot\".name");
|
||||
assert_eq!(
|
||||
(issue.line, issue.column),
|
||||
(Some(line), Some(column)),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_definition_has_no_invented_line_and_names_each_provider() {
|
||||
let fixture = Fixture::new("model_provider='openai'\n");
|
||||
fixture.sessions(&["old_proxy_a", "old_proxy_b"]);
|
||||
let report = fixture.check();
|
||||
assert_eq!(report.issues.len(), 2);
|
||||
assert!(!report.can_repair);
|
||||
for issue in &report.issues {
|
||||
assert_eq!(issue.path, config_path(&fixture.0).display().to_string());
|
||||
assert_eq!((issue.line, issue.column), (None, None));
|
||||
assert!(issue.key.starts_with("model_providers.old_proxy_"));
|
||||
assert!(issue.description.contains(&issue.key));
|
||||
assert!(issue.suggestion.contains(&issue.key));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_errors_include_parameter_context_and_safe_actionable_suggestions() {
|
||||
let text = "model_provider='custom'\n[model_providers.custom]\nname='OpenAI'\nexperimental_bearer_token = 'never-echo-this-secret\n";
|
||||
let fixture = Fixture::new(text);
|
||||
let report = fixture.check();
|
||||
let issue = &report.issues[0];
|
||||
assert_eq!(
|
||||
issue.key,
|
||||
"model_providers.custom.experimental_bearer_token"
|
||||
);
|
||||
assert_eq!(issue.line, Some(4));
|
||||
assert!(issue.column.is_some());
|
||||
assert!(issue.suggestion.contains("引号"));
|
||||
assert!(!serde_json::to_string(&report)
|
||||
.unwrap()
|
||||
.contains("never-echo-this-secret"));
|
||||
assert!(!fixture.repair(&report).changed);
|
||||
assert_eq!(fixture.text(), text);
|
||||
assert!(!fixture.0.join(".codexx-test-backups").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_context_respects_delimiters_inside_quoted_keys() {
|
||||
let text = "[model_providers.\"name]with=chars\"]\n\"key=with]chars\" = 'secret-without-closing-quote\n";
|
||||
let keys = syntax_key(text, text.len() - 1);
|
||||
assert_eq!(
|
||||
keys,
|
||||
["model_providers", "name]with=chars", "key=with]chars"]
|
||||
);
|
||||
assert!(!key_path(&keys.iter().map(String::as_str).collect::<Vec<_>>()).contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_official_login_can_repair_all_missing_history_aliases_without_touching_sessions() {
|
||||
for selection in ["model_provider='openai'\n", ""] {
|
||||
let original = format!(
|
||||
"{selection}model='official-model'\n[mcp_servers.fixture]\ncommand='fixture-mcp'\n"
|
||||
);
|
||||
let fixture = Fixture::new(&original);
|
||||
fixture.login();
|
||||
fixture.sessions(&["old_proxy_a", "old_proxy_b", "openai"]);
|
||||
let db_before = fs::read(fixture.0.join("state_5.sqlite")).unwrap();
|
||||
let auth_before = fs::read(auth_path(&fixture.0)).unwrap();
|
||||
let report = fixture.check();
|
||||
assert!(report.can_repair);
|
||||
assert_eq!(report.issues.len(), 2);
|
||||
assert!(report
|
||||
.repair_summary
|
||||
.iter()
|
||||
.all(|summary| summary.contains("OpenAI")));
|
||||
assert_eq!(fixture.text(), original);
|
||||
let repaired = fixture.repair(&report);
|
||||
assert!(repaired.changed);
|
||||
assert_eq!(repaired.report.status, "healthy");
|
||||
let backup = fixture
|
||||
.0
|
||||
.join(".codexx-test-backups")
|
||||
.join(repaired.backup_id.unwrap())
|
||||
.join("config.toml");
|
||||
assert_eq!(fs::read_to_string(backup).unwrap(), original);
|
||||
let doc = fixture.text().parse::<DocumentMut>().unwrap();
|
||||
assert_eq!(
|
||||
doc.get("model_provider").and_then(Item::as_str),
|
||||
if selection.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some("openai")
|
||||
}
|
||||
);
|
||||
assert_eq!(doc["model"].as_str(), Some("official-model"));
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["fixture"]["command"].as_str(),
|
||||
Some("fixture-mcp")
|
||||
);
|
||||
for id in ["old_proxy_a", "old_proxy_b"] {
|
||||
assert_eq!(doc["model_providers"][id]["name"].as_str(), Some("OpenAI"));
|
||||
assert_eq!(
|
||||
doc["model_providers"][id]["wire_api"].as_str(),
|
||||
Some("responses")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"][id]["requires_openai_auth"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert!(doc["model_providers"][id]
|
||||
.as_table_like()
|
||||
.unwrap()
|
||||
.get("base_url")
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("state_5.sqlite")).unwrap(),
|
||||
db_before
|
||||
);
|
||||
assert_eq!(fs::read(auth_path(&fixture.0)).unwrap(), auth_before);
|
||||
assert!(!fixture.repair(&repaired.report).changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_custom_official_login_can_repair_history_with_multiple_inactive_sources() {
|
||||
let original = "model_provider='custom'\n[model_providers.custom]\nname='OpenAI'\nrequires_openai_auth=true\nsupports_websockets=true\nwire_api='responses'\n[model_providers.inactive]\nname='Inactive proxy'\nbase_url='https://inactive.fixture/v1'\n";
|
||||
let fixture = Fixture::new(original);
|
||||
fixture.login();
|
||||
fixture.sessions(&["old_proxy_a", "old_proxy_b"]);
|
||||
let report = fixture.check();
|
||||
assert!(report.can_repair);
|
||||
assert_eq!(fixture.repair(&report).report.status, "healthy");
|
||||
let doc = fixture.text().parse::<DocumentMut>().unwrap();
|
||||
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["old_proxy_a"]["supports_websockets"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["inactive"]["base_url"].as_str(),
|
||||
Some("https://inactive.fixture/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_history_repair_requires_login_and_unambiguous_current_routing() {
|
||||
for text in [
|
||||
"model_provider='openai'\n",
|
||||
"model_provider='custom'\n[model_providers.custom]\nname='OpenAI'\nrequires_openai_auth=true\nwire_api='responses'\n",
|
||||
] {
|
||||
let fixture = Fixture::new(text);
|
||||
fixture.sessions(&["old_proxy"]);
|
||||
assert!(!fixture.check().can_repair, "{text}");
|
||||
assert_eq!(fixture.text(), text);
|
||||
}
|
||||
for root in [
|
||||
"base_url='https://proxy.fixture'",
|
||||
"env_key='FIXTURE_API_KEY'",
|
||||
"http_headers={Authorization='Bearer fixture'}",
|
||||
"forced_login_method='api'",
|
||||
"model_provider=5",
|
||||
] {
|
||||
let original = if root.starts_with("model_provider") {
|
||||
format!("{root}\n")
|
||||
} else {
|
||||
format!("model_provider='openai'\n{root}\n")
|
||||
};
|
||||
let fixture = Fixture::new(&original);
|
||||
fixture.login();
|
||||
fixture.sessions(&["old_proxy"]);
|
||||
let report = fixture.check();
|
||||
assert!(!report.can_repair, "{root}");
|
||||
assert!(!fixture.repair(&report).changed);
|
||||
assert_eq!(fixture.text(), original);
|
||||
}
|
||||
for extra in [
|
||||
"env_key='FIXTURE_API_KEY'",
|
||||
"http_headers={Authorization='Bearer fixture'}",
|
||||
"query_params={token='fixture'}",
|
||||
] {
|
||||
let original = format!("model_provider='custom'\n[model_providers.custom]\nname='OpenAI'\nrequires_openai_auth=true\nwire_api='responses'\n{extra}\n");
|
||||
let fixture = Fixture::new(&original);
|
||||
fixture.login();
|
||||
fixture.sessions(&["old_proxy"]);
|
||||
assert!(!fixture.check().can_repair, "{extra}");
|
||||
assert_eq!(fixture.text(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_removal_and_external_edit_cancel_official_alias_repairs() {
|
||||
let original = "model_provider='openai'\n";
|
||||
let fixture = Fixture::new(original);
|
||||
fixture.login();
|
||||
fixture.sessions(&["old_proxy"]);
|
||||
let report = fixture.check();
|
||||
assert!(report.can_repair);
|
||||
let result = repair_with_before_write(&fixture.0, &report.fingerprint, || {
|
||||
fs::remove_file(auth_path(&fixture.0)).unwrap();
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fixture.text(), original);
|
||||
fixture.login();
|
||||
let report = fixture.check();
|
||||
let result = repair_with_before_write(&fixture.0, &report.fingerprint, || {
|
||||
fs::write(config_path(&fixture.0), "# external edit\n").unwrap();
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fixture.text(), "# external edit\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -141,6 +141,13 @@ pub(crate) fn report_failover_lifecycle_error(app: &tauri::AppHandle, title: &st
|
||||
|
||||
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = &event {
|
||||
if crate::app_update::defer_exit_during_installation() {
|
||||
// The installed bundle or updater handoff is being changed. A
|
||||
// normal Quit must not interrupt it; Windows marks its authorized
|
||||
// handoff before requesting process exit.
|
||||
api.prevent_exit();
|
||||
return;
|
||||
}
|
||||
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.
|
||||
@@ -149,7 +156,9 @@ pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
report_failover_lifecycle_error(
|
||||
app,
|
||||
"暂时无法退出 Codex-X",
|
||||
&format!("自动切换的连接配置还未恢复,Codex-X 将继续运行。\n\n{error}\n\n请检查配置文件是否被占用,再重试退出。"),
|
||||
&format!(
|
||||
"路由仍需保持可用,Codex-X 将继续运行。\n\n{error}\n\n请按提示处理后再退出。"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use super::config::{self, RoutingTuning, MAX_QUEUE, ROUTE_GENERATION_HEADER, ROUTE_TOKEN_HEADER};
|
||||
use super::native_official;
|
||||
use super::protocol::UpstreamApi;
|
||||
use super::proxy::{ProxyHandle, ProxyOptions, ProxyRoute, ProxySelectionEvent, ProxySnapshot};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::parse_toml_document;
|
||||
@@ -84,6 +85,8 @@ struct ProxyJournal {
|
||||
table_existed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
providers_existed: bool,
|
||||
#[serde(default)]
|
||||
requires_conversion: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize, Deserialize)]
|
||||
@@ -552,9 +555,8 @@ fn provider_models(provider: &SavedProvider) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn route(provider: &SavedProvider) -> Result<ProxyRoute> {
|
||||
if provider.wire_api != "responses" {
|
||||
return Err(CodexxError::Config("需要支持 Responses 的供应商".into()));
|
||||
}
|
||||
let protocol =
|
||||
UpstreamApi::from_provider(provider.upstream_api.as_deref(), &provider.wire_api)?;
|
||||
let url = reqwest::Url::parse(provider.base_url.trim())
|
||||
.map_err(|_| CodexxError::Config("供应商地址无效".into()))?;
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
@@ -624,9 +626,6 @@ fn route(provider: &SavedProvider) -> Result<ProxyRoute> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if provider.requires_openai_auth && api_key.is_none() {
|
||||
return Err(CodexxError::Config("请先填写该供应商的 API Key".into()));
|
||||
}
|
||||
if let Some(key) = &api_key {
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {key}"))
|
||||
.map_err(|_| CodexxError::Config("供应商 API Key 格式不正确".into()))?;
|
||||
@@ -643,7 +642,6 @@ fn route(provider: &SavedProvider) -> Result<ProxyRoute> {
|
||||
| "content-length"
|
||||
| "transfer-encoding"
|
||||
| "cookie"
|
||||
| "authorization"
|
||||
| "proxy-authorization"
|
||||
| "origin"
|
||||
) {
|
||||
@@ -670,7 +668,21 @@ fn route(provider: &SavedProvider) -> Result<ProxyRoute> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if provider.requires_openai_auth
|
||||
&& api_key.is_none()
|
||||
&& !headers.iter().any(|(name, _)| {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"authorization" | "x-api-key" | "x-goog-api-key"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"请先填写该供应商的 API Key 或认证请求头".into(),
|
||||
));
|
||||
}
|
||||
Ok(ProxyRoute {
|
||||
protocol,
|
||||
official: None,
|
||||
id: provider.id.clone(),
|
||||
name: provider.provider_name.clone(),
|
||||
@@ -900,6 +912,7 @@ fn attach_route(dir: &Path, record: &mut Record, runtime: &mut Running) -> Resul
|
||||
official: primary.official.is_some(),
|
||||
table_existed: original.is_some(),
|
||||
providers_existed: direct.get("model_providers").is_some(),
|
||||
requires_conversion: primary.protocol.requires_routing(),
|
||||
};
|
||||
replace_table(
|
||||
&mut direct,
|
||||
@@ -1155,7 +1168,10 @@ pub(crate) fn get_status(config_dir: Option<String>) -> Result<FailoverStatus> {
|
||||
let dir = directory(config_dir)?;
|
||||
let mut runtimes = lock_manager()?;
|
||||
let mut record = load_record(&dir)?;
|
||||
if let Some(runtime) = runtimes.get_mut(&dir) {
|
||||
if let Some(runtime) = runtimes
|
||||
.get_mut(&dir)
|
||||
.filter(|_| !SHUTTING_DOWN.load(Ordering::Acquire))
|
||||
{
|
||||
inspect_external_change(&dir, &mut record, runtime)?;
|
||||
}
|
||||
status_locked(&dir, record, runtimes.get(&dir))
|
||||
@@ -1177,6 +1193,14 @@ pub(crate) fn save_settings(
|
||||
if let Some(runtime) = runtimes.get_mut(&dir) {
|
||||
inspect_external_change(&dir, &mut old, runtime)?;
|
||||
}
|
||||
if (!settings.router_enabled || !settings.takeover_enabled)
|
||||
&& runtimes
|
||||
.get(&dir)
|
||||
.and_then(|runtime| runtime.journal.as_ref())
|
||||
.is_some_and(|journal| journal.requires_conversion)
|
||||
{
|
||||
return Err(CodexxError::Config("当前供应商需要协议转换。请先切换到 Responses 供应商或官方账号,再关闭 Codex 接管或路由服务。".into()));
|
||||
}
|
||||
if let Some(runtime) = runtimes.get(&dir) {
|
||||
if settings.router_enabled
|
||||
&& (config::listen_ip(&settings.listen_address)? != runtime.proxy.listen_address()
|
||||
@@ -1289,27 +1313,47 @@ pub(crate) fn with_provider_change<T>(
|
||||
) -> Result<T> {
|
||||
let dir = directory(config_dir)?;
|
||||
let mut runtimes = lock_manager()?;
|
||||
if SHUTTING_DOWN.load(Ordering::Acquire) {
|
||||
return Err(CodexxError::Config(
|
||||
"Codex-X 正在退出或交接更新,未更改供应商配置".into(),
|
||||
));
|
||||
}
|
||||
let mut record = load_record(&dir)?;
|
||||
let Some(runtime) = runtimes.get_mut(&dir) else {
|
||||
drop(runtimes);
|
||||
recover_stale_route(Some(dir.display().to_string()))?;
|
||||
// A converted stale route cannot be restored to a supplier's raw URL.
|
||||
// The explicit provider action can replace it with a new valid route.
|
||||
if !record
|
||||
.journals
|
||||
.iter()
|
||||
.any(|journal| journal.requires_conversion)
|
||||
{
|
||||
recover_stale_route(Some(dir.display().to_string()))?;
|
||||
}
|
||||
return action();
|
||||
};
|
||||
let reconnect = runtime.journal.is_some() && record.settings.takeover_enabled;
|
||||
let checkpoint = FileCheckpoint::capture(&dir)?;
|
||||
let old_record = record.clone();
|
||||
let old_journal = runtime.journal.clone();
|
||||
let old_routes = runtime.routes.clone();
|
||||
let old_options = runtime.options.clone();
|
||||
if reconnect {
|
||||
detach_route(&dir, &record, runtime)?;
|
||||
}
|
||||
let result = action();
|
||||
if reconnect {
|
||||
if let Err(error) = attach_route(&dir, &mut record, runtime) {
|
||||
record.settings.takeover_enabled = false;
|
||||
record.message = Some(format!(
|
||||
"供应商操作已结束,但重新接管失败;当前使用直连:{error}"
|
||||
));
|
||||
save_record(&dir, &record)?;
|
||||
let expected = FileCheckpoint::capture(&dir)?;
|
||||
checkpoint.restore(&dir, &expected)?;
|
||||
save_record(&dir, &old_record)?;
|
||||
runtime.journal = old_journal;
|
||||
update_runtime(runtime, old_routes, old_options)?;
|
||||
changed(&dir);
|
||||
return match result {
|
||||
Ok(_) => Err(CodexxError::Config(record.message.unwrap())),
|
||||
Ok(_) => Err(CodexxError::Config(format!(
|
||||
"供应商重新接管失败,已恢复原配置和路由:{error}"
|
||||
))),
|
||||
Err(original) => Err(original),
|
||||
};
|
||||
}
|
||||
@@ -1318,11 +1362,53 @@ pub(crate) fn with_provider_change<T>(
|
||||
result
|
||||
}
|
||||
|
||||
/// Call at the IPC boundary before with_provider_change acquires the manager
|
||||
/// lock. Internal P1/failover changes already run under validated takeover.
|
||||
pub(crate) fn ensure_conversion_route(
|
||||
config_dir: Option<String>,
|
||||
provider: &SavedProvider,
|
||||
) -> Result<()> {
|
||||
let conversion =
|
||||
UpstreamApi::from_provider(provider.upstream_api.as_deref(), &provider.wire_api)?
|
||||
.requires_routing();
|
||||
let dir = directory(config_dir)?;
|
||||
let runtimes = lock_manager()?;
|
||||
let record = load_record(&dir)?;
|
||||
if runtimes
|
||||
.get(&dir)
|
||||
.is_some_and(|runtime| runtime.journal.is_some())
|
||||
{
|
||||
route(provider)?;
|
||||
}
|
||||
if !conversion {
|
||||
return Ok(());
|
||||
}
|
||||
if !record.settings.router_enabled
|
||||
|| !record.settings.takeover_enabled
|
||||
|| runtimes
|
||||
.get(&dir)
|
||||
.is_none_or(|runtime| runtime.journal.is_none())
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"该供应商需要协议转换。请先开启路由总开关和 Codex 请求接管,再启用该供应商。".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn recover_stale_route(config_dir: Option<String>) -> Result<()> {
|
||||
let dir = directory(config_dir)?;
|
||||
let record = load_record(&dir)?;
|
||||
if dir.exists() && !record.journals.is_empty() {
|
||||
let _guard = acquire_live_config_lock(&dir)?;
|
||||
let doc = read_document(&dir)?.1;
|
||||
if record
|
||||
.journals
|
||||
.iter()
|
||||
.any(|journal| journal.requires_conversion && owned_route(&doc, journal))
|
||||
{
|
||||
return Err(CodexxError::Config("当前供应商需要协议转换,不能恢复为直连。请重新启动路由服务,或切换到 Responses 供应商/官方账号。".into()));
|
||||
}
|
||||
restore_owned_locked(&dir, &record)?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -1337,6 +1423,16 @@ fn refresh_routes_locked(dir: &Path, record: &mut Record, runtime: &mut Running)
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
if runtime
|
||||
.journal
|
||||
.as_ref()
|
||||
.is_some_and(|journal| journal.requires_conversion)
|
||||
{
|
||||
record.message=Some(format!("协议转换供应商配置不可用,已保留原路由;请修正配置或切换到 Responses 供应商:{error}"));
|
||||
save_record(dir, record)?;
|
||||
changed(dir);
|
||||
return Ok(());
|
||||
}
|
||||
detach_route(dir, record, runtime)?;
|
||||
record.settings.takeover_enabled = false;
|
||||
record.message = Some(format!("当前供应商配置不可用,已恢复直连:{error}"));
|
||||
@@ -1348,6 +1444,11 @@ fn refresh_routes_locked(dir: &Path, record: &mut Record, runtime: &mut Running)
|
||||
}
|
||||
pub(crate) fn refresh_saved_routes() -> Result<()> {
|
||||
let mut runtimes = lock_manager()?;
|
||||
if SHUTTING_DOWN.load(Ordering::Acquire) {
|
||||
return Err(CodexxError::Config(
|
||||
"Codex-X 正在退出或交接更新,未更改路由配置".into(),
|
||||
));
|
||||
}
|
||||
for (dir, runtime) in runtimes.iter_mut() {
|
||||
let mut record = load_record(dir)?;
|
||||
refresh_routes_locked(dir, &mut record, runtime)?;
|
||||
@@ -1370,6 +1471,9 @@ fn record_selection(
|
||||
notice: SelectionNotice,
|
||||
runtimes: &mut HashMap<PathBuf, Running>,
|
||||
) -> Result<()> {
|
||||
if SHUTTING_DOWN.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(runtime) = runtimes.get_mut(¬ice.dir) else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1425,6 +1529,9 @@ fn record_selection(
|
||||
*original.as_table_mut() = table;
|
||||
let mut next = journal.clone();
|
||||
next.primary_id = provider.id.clone();
|
||||
next.requires_conversion =
|
||||
UpstreamApi::from_provider(provider.upstream_api.as_deref(), &provider.wire_api)?
|
||||
.requires_routing();
|
||||
next.original_table = original.to_string();
|
||||
next.lease_id = Some(random_token()?);
|
||||
let _guard = acquire_live_config_lock(¬ice.dir)?;
|
||||
@@ -1506,7 +1613,16 @@ pub(crate) fn initialize() -> Result<()> {
|
||||
}
|
||||
let mut record = load_record(&dir)?;
|
||||
let desired = record.settings.takeover_enabled;
|
||||
if !record.journals.is_empty() && dir.exists() {
|
||||
let converted_owned = if dir.exists() {
|
||||
let doc = read_document(&dir)?.1;
|
||||
record
|
||||
.journals
|
||||
.iter()
|
||||
.any(|journal| journal.requires_conversion && owned_route(&doc, journal))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !converted_owned && !record.journals.is_empty() && dir.exists() {
|
||||
let _guard = acquire_live_config_lock(&dir)?;
|
||||
restore_owned_locked(&dir, &record)?;
|
||||
}
|
||||
@@ -1518,6 +1634,11 @@ pub(crate) fn initialize() -> Result<()> {
|
||||
match normalized_settings(record.settings.clone()) {
|
||||
Ok(settings) => record.settings = settings,
|
||||
Err(error) => {
|
||||
if converted_owned {
|
||||
record.message=Some(format!("协议转换路由无法启动,已保留本地接管配置;请检查端口或切换到 Responses 供应商:{error}"));
|
||||
save_record(&dir, &record)?;
|
||||
continue;
|
||||
}
|
||||
record.settings.router_enabled = false;
|
||||
record.settings.takeover_enabled = false;
|
||||
record.message = Some(format!("路由设置需要检查,当前使用直连:{error}"));
|
||||
@@ -1548,6 +1669,11 @@ pub(crate) fn initialize() -> Result<()> {
|
||||
saved?;
|
||||
}
|
||||
Err(error) => {
|
||||
if converted_owned {
|
||||
record.message=Some(format!("协议转换路由无法启动,已保留本地接管配置;请检查端口或切换到 Responses 供应商:{error}"));
|
||||
save_record(&dir, &record)?;
|
||||
continue;
|
||||
}
|
||||
record.settings.router_enabled = false;
|
||||
record.settings.takeover_enabled = false;
|
||||
record.message = Some(format!("路由未启动,当前使用直连:{error}"));
|
||||
@@ -1567,6 +1693,9 @@ fn ensure_watcher() {
|
||||
let Ok(mut runtimes) = lock_manager() else {
|
||||
continue;
|
||||
};
|
||||
if SHUTTING_DOWN.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
let events: Vec<_> = notices()
|
||||
.1
|
||||
.lock()
|
||||
@@ -1592,17 +1721,82 @@ fn ensure_watcher() {
|
||||
});
|
||||
});
|
||||
}
|
||||
fn shutdown_allowed_locked(runtimes: &HashMap<PathBuf, Running>) -> Result<()> {
|
||||
for (dir, runtime) in runtimes {
|
||||
let Some(journal) = runtime.journal.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let doc = read_document(dir)?.1;
|
||||
if journal.requires_conversion && owned_route(&doc, journal) {
|
||||
let record = load_record(dir)?;
|
||||
let persisted=record.journals.iter().find(|saved| saved.requires_conversion && saved.primary_id==journal.primary_id && saved.provider_key==journal.provider_key && saved.token==journal.token && saved.lease_id==journal.lease_id && owned_route(&doc,saved))
|
||||
.ok_or_else(||CodexxError::Config("转换路由的恢复记录缺失,服务继续运行;请重新启用 Codex 接管后重试退出或更新。".into()))?;
|
||||
original_table(persisted)?;
|
||||
} else if !journal.requires_conversion {
|
||||
// Validate the existing native restore projection without writing.
|
||||
restored_document(&doc, journal)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only check for exit/update callers before they modify the application.
|
||||
pub(crate) fn ensure_shutdown_allowed() -> Result<()> {
|
||||
let runtimes = lock_manager()?;
|
||||
shutdown_allowed_locked(&runtimes)
|
||||
}
|
||||
|
||||
/// Freeze configuration mutations during an update while keeping active
|
||||
/// requests and the listener alive until the installer needs the process exit.
|
||||
pub(crate) fn begin_update_handoff() -> Result<()> {
|
||||
let runtimes = lock_manager()?;
|
||||
shutdown_allowed_locked(&runtimes)?;
|
||||
SHUTTING_DOWN.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_all() -> Result<()> {
|
||||
SHUTTING_DOWN.store(true, Ordering::Release);
|
||||
let result = (|| -> Result<()> {
|
||||
let mut runtimes = lock_manager()?;
|
||||
shutdown_allowed_locked(&runtimes)?;
|
||||
let dirs: Vec<_> = runtimes.keys().cloned().collect();
|
||||
for dir in dirs {
|
||||
let record = load_record(&dir)?;
|
||||
let mut record = load_record(&dir)?;
|
||||
if let Some(runtime) = runtimes.get_mut(&dir) {
|
||||
detach_route(&dir, &record, runtime)?;
|
||||
if let Some(journal) = runtime
|
||||
.journal
|
||||
.as_ref()
|
||||
.filter(|journal| journal.requires_conversion)
|
||||
{
|
||||
let previous_settings = record.settings.clone();
|
||||
let previous_message = record.message.clone();
|
||||
let doc = read_document(&dir)?.1;
|
||||
if !selected_owned_route(&doc, journal) {
|
||||
// An explicit external selection is kept exactly as it
|
||||
// is; the next launch must not take it over implicitly.
|
||||
record.settings.takeover_enabled = false;
|
||||
record.message = Some(
|
||||
"退出时检测到外部供应商配置,已原样保留;下次启动不会自动接管该配置。"
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
record.settings.router_enabled = true;
|
||||
record.settings.takeover_enabled = true;
|
||||
record.settings.listen_address = runtime.proxy.listen_address().to_string();
|
||||
record.settings.listen_port = runtime.proxy.port();
|
||||
}
|
||||
if record.settings != previous_settings || record.message != previous_message {
|
||||
save_record(&dir, &record)?;
|
||||
}
|
||||
// Keep the owned loopback overlay and recovery journal.
|
||||
// initialize reconnects it on the next app launch. Restoring
|
||||
// this supplier's raw URL would send an invalid /responses.
|
||||
} else {
|
||||
detach_route(&dir, &record, runtime)?;
|
||||
}
|
||||
}
|
||||
// Keep desired settings for next launch; restore first, stop last.
|
||||
// All required writes/validation finish before stopping a listener.
|
||||
if let Some(runtime) = runtimes.remove(&dir) {
|
||||
runtime.proxy.shutdown();
|
||||
}
|
||||
@@ -1617,7 +1811,6 @@ pub(crate) fn shutdown_all() -> Result<()> {
|
||||
|
||||
/// Only an update handoff that has not launched an installer may resume the
|
||||
/// saved routes. A successful handoff keeps the shutdown gate closed until exit.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
pub(crate) fn resume_after_failed_update() -> Result<()> {
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
let expected: Vec<_> = stored_directories()?
|
||||
|
||||
@@ -2,13 +2,14 @@ mod circuit_breaker;
|
||||
pub(crate) mod config;
|
||||
mod controller;
|
||||
pub(crate) mod native_official;
|
||||
pub(crate) mod protocol;
|
||||
mod proxy;
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
pub(crate) use controller::resume_after_failed_update;
|
||||
|
||||
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,
|
||||
attach_app_handle, begin_update_handoff, direct_document, ensure_conversion_route,
|
||||
ensure_shutdown_allowed, get_status, initialize, recover_stale_route, refresh_saved_routes,
|
||||
reset_health, save_settings, shutdown_all, with_provider_change, FailoverSettings,
|
||||
FailoverStatus,
|
||||
};
|
||||
|
||||
@@ -182,6 +182,7 @@ pub(crate) fn route_for_current(dir: &Path) -> Result<Option<ProxyRoute>> {
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(Some(ProxyRoute {
|
||||
protocol: super::protocol::UpstreamApi::Responses,
|
||||
id: format!("official:{profile_id}"),
|
||||
name,
|
||||
base_url,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
use super::{encode_thinking, ToolContext, UpstreamApi};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) fn error_message(value: &Value) -> Option<String> {
|
||||
let error = value.get("error").filter(|v| !v.is_null()).or_else(|| {
|
||||
(value.get("type").and_then(Value::as_str) == Some("error")).then_some(value)
|
||||
})?;
|
||||
Some(
|
||||
error
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| {
|
||||
error
|
||||
.get("message")
|
||||
.or_else(|| error.get("detail"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| error.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn convert_response(
|
||||
api: UpstreamApi,
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
requested_model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
if let Some(error) = error_message(body) {
|
||||
return Err(error);
|
||||
}
|
||||
match api {
|
||||
UpstreamApi::Responses => Ok(body.clone()),
|
||||
UpstreamApi::ChatCompletions => chat_response(body, context, requested_model),
|
||||
UpstreamApi::AnthropicMessages => anthropic_response(body, context, requested_model),
|
||||
UpstreamApi::Gemini => gemini_response(body, context, requested_model),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn response_id(id: Option<&str>) -> String {
|
||||
if let Some(id) = id.filter(|s| !s.is_empty()) {
|
||||
let digest = Sha256::digest(id.as_bytes());
|
||||
return format!(
|
||||
"resp_codexx_{}",
|
||||
digest
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"resp_codexx_{}",
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn envelope(
|
||||
id: &str,
|
||||
model: &str,
|
||||
status: &str,
|
||||
output: Vec<Value>,
|
||||
usage: Value,
|
||||
reason: Option<&str>,
|
||||
) -> Value {
|
||||
let mut value = json!({"id":id,"object":"response","created_at":chrono::Utc::now().timestamp(),"model":model,"status":status,"output":output,"usage":usage,"error":null,"incomplete_details":null});
|
||||
if let Some(reason) = reason {
|
||||
value["incomplete_details"] = json!({"reason":reason});
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(super) fn text_item(id: &str, text: &str) -> Value {
|
||||
json!({"id":id,"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":text,"annotations":[]}]})
|
||||
}
|
||||
|
||||
pub(super) fn reasoning_item(id: &str, text: &str, state: Option<Value>) -> Value {
|
||||
let mut value = json!({"id":id,"type":"reasoning","summary":if text.is_empty(){json!([])}else{json!([{"type":"summary_text","text":text}])}});
|
||||
if let Some(state) = state {
|
||||
value["encrypted_content"] = json!(encode_thinking(&state));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(super) fn tool_item(
|
||||
context: &ToolContext,
|
||||
id: &str,
|
||||
call_id: &str,
|
||||
upstream_name: &str,
|
||||
arguments: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
if upstream_name.is_empty() {
|
||||
return Err("上游工具调用缺少名称".into());
|
||||
}
|
||||
let spec = context.names.get(upstream_name);
|
||||
let mut value = if spec.is_some_and(|s| s.custom) {
|
||||
let arguments: Value =
|
||||
serde_json::from_str(arguments).map_err(|_| "上游 custom 工具参数不是有效的 JSON")?;
|
||||
let input = arguments
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("上游 custom 工具参数缺少 input 文字")?;
|
||||
json!({"id":id,"type":"custom_tool_call","call_id":call_id,"name":spec.unwrap().name,"input":input,"status":"completed"})
|
||||
} else {
|
||||
let parsed: Value =
|
||||
serde_json::from_str(arguments).map_err(|_| "上游工具参数不是有效的 JSON")?;
|
||||
if !parsed.is_object() {
|
||||
return Err("上游工具参数须是 JSON 对象".into());
|
||||
}
|
||||
json!({"id":id,"type":"function_call","call_id":call_id,"name":spec.map_or(upstream_name,|s|s.name.as_str()),"arguments":arguments,"status":"completed"})
|
||||
};
|
||||
if let Some(namespace) = spec.and_then(|s| s.namespace.as_deref()) {
|
||||
value["namespace"] = json!(namespace);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn terminal(
|
||||
api: UpstreamApi,
|
||||
reason: Option<&str>,
|
||||
) -> (&'static str, Option<&'static str>) {
|
||||
match (api, reason) {
|
||||
(_, Some("length" | "max_tokens" | "MAX_TOKENS" | "model_context_window_exceeded")) => {
|
||||
("incomplete", Some("max_output_tokens"))
|
||||
}
|
||||
(
|
||||
_,
|
||||
Some(
|
||||
"content_filter" | "refusal" | "SAFETY" | "BLOCKLIST" | "PROHIBITED_CONTENT"
|
||||
| "RECITATION" | "SPII" | "IMAGE_SAFETY",
|
||||
),
|
||||
) => ("incomplete", Some("content_filter")),
|
||||
(_, Some("MALFORMED_FUNCTION_CALL" | "UNEXPECTED_TOOL_CALL")) => {
|
||||
("incomplete", Some("tool_call_error"))
|
||||
}
|
||||
_ => ("completed", None),
|
||||
}
|
||||
}
|
||||
|
||||
fn tokens(value: Option<&Value>) -> u64 {
|
||||
value.and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
pub(super) fn usage(api: UpstreamApi, value: Option<&Value>) -> Value {
|
||||
let empty = json!({});
|
||||
let u = value.unwrap_or(&empty);
|
||||
let (input, output, cached, reasoning, write) = match api {
|
||||
UpstreamApi::ChatCompletions => (
|
||||
tokens(u.get("prompt_tokens")),
|
||||
tokens(u.get("completion_tokens")),
|
||||
tokens(u.pointer("/prompt_tokens_details/cached_tokens"))
|
||||
.max(tokens(u.get("prompt_cache_hit_tokens"))),
|
||||
tokens(u.pointer("/completion_tokens_details/reasoning_tokens")),
|
||||
tokens(u.get("cache_creation_input_tokens")),
|
||||
),
|
||||
UpstreamApi::AnthropicMessages => {
|
||||
let cached = tokens(u.get("cache_read_input_tokens"));
|
||||
let write = tokens(u.get("cache_creation_input_tokens"));
|
||||
(
|
||||
tokens(u.get("input_tokens"))
|
||||
.saturating_add(cached)
|
||||
.saturating_add(write),
|
||||
tokens(u.get("output_tokens")),
|
||||
cached,
|
||||
tokens(u.get("thinking_tokens")),
|
||||
write,
|
||||
)
|
||||
}
|
||||
UpstreamApi::Gemini => {
|
||||
let reasoning = tokens(u.get("thoughtsTokenCount"));
|
||||
(
|
||||
tokens(u.get("promptTokenCount")),
|
||||
tokens(u.get("candidatesTokenCount")).saturating_add(reasoning),
|
||||
tokens(u.get("cachedContentTokenCount")),
|
||||
reasoning,
|
||||
0,
|
||||
)
|
||||
}
|
||||
UpstreamApi::Responses => return u.clone(),
|
||||
};
|
||||
json!({"input_tokens":input,"output_tokens":output,"total_tokens":input.saturating_add(output),"input_tokens_details":{"cached_tokens":cached,"cache_write_tokens":write},"output_tokens_details":{"reasoning_tokens":reasoning}})
|
||||
}
|
||||
|
||||
fn chat_response(
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
let choice = body
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|v| v.first())
|
||||
.ok_or("Chat 响应缺少 choices")?;
|
||||
let message = choice.get("message").ok_or("Chat 响应缺少 message")?;
|
||||
let id = response_id(body.get("id").and_then(Value::as_str));
|
||||
let mut output = Vec::new();
|
||||
if let Some(thinking) = chat_reasoning(message) {
|
||||
output.push(reasoning_item(&format!("rs_{id}"), &thinking, None));
|
||||
}
|
||||
let text = match message.get("content") {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
if matches!(
|
||||
part.get("type").and_then(Value::as_str),
|
||||
Some("text" | "output_text")
|
||||
) {
|
||||
text.push_str(
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Chat 文字内容缺少 text")?,
|
||||
);
|
||||
} else {
|
||||
return Err("Chat 响应含不能转换的非文字内容".into());
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
if !text.is_empty() {
|
||||
output.push(text_item(&format!("msg_{id}"), &text));
|
||||
}
|
||||
if let Some(refusal) = message.get("refusal").and_then(Value::as_str) {
|
||||
output.push(json!({"id":format!("msg_refusal_{id}"),"type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":refusal}]}));
|
||||
}
|
||||
if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
|
||||
for (index, call) in calls.iter().enumerate() {
|
||||
let function = call.get("function").ok_or("Chat 工具调用缺少 function")?;
|
||||
let call_id = call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("call_{id}_{index}"));
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_{index}"),
|
||||
&call_id,
|
||||
function.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
function
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("{}"),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
if let Some(call) = message.get("function_call") {
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_legacy"),
|
||||
&format!("call_{id}_legacy"),
|
||||
call.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
call.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("{}"),
|
||||
)?);
|
||||
}
|
||||
let reason = choice
|
||||
.get("finish_reason")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Chat 响应缺少 finish_reason,无法确认完整完成")?;
|
||||
let (status, incomplete) = terminal(UpstreamApi::ChatCompletions, Some(reason));
|
||||
Ok(envelope(
|
||||
&id,
|
||||
body.get("model").and_then(Value::as_str).unwrap_or(model),
|
||||
status,
|
||||
output,
|
||||
usage(UpstreamApi::ChatCompletions, body.get("usage")),
|
||||
incomplete,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn chat_reasoning(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("reasoning_content")
|
||||
.or_else(|| value.get("reasoning"))
|
||||
.and_then(|v| match v {
|
||||
Value::String(s) if !s.is_empty() => Some(s.clone()),
|
||||
Value::Array(items) => Some(
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|v| v.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn anthropic_response(
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
let blocks = body
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("Anthropic 响应缺少 content")?;
|
||||
let id = response_id(body.get("id").and_then(Value::as_str));
|
||||
let mut output = Vec::new();
|
||||
for (index, block) in blocks.iter().enumerate() {
|
||||
match block.get("type").and_then(Value::as_str) {
|
||||
Some("text") => output.push(text_item(
|
||||
&format!("msg_{id}_{index}"),
|
||||
block.get("text").and_then(Value::as_str).unwrap_or(""),
|
||||
)),
|
||||
Some("thinking" | "redacted_thinking") => output.push(reasoning_item(
|
||||
&format!("rs_{id}_{index}"),
|
||||
block.get("thinking").and_then(Value::as_str).unwrap_or(""),
|
||||
Some(json!({"protocol":"anthropic","block":block})),
|
||||
)),
|
||||
Some("tool_use") => {
|
||||
let call_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Anthropic tool_use 缺少 id")?;
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_{index}"),
|
||||
call_id,
|
||||
block.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
&block
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}))
|
||||
.to_string(),
|
||||
)?);
|
||||
}
|
||||
Some(other) => return Err(format!("无法转换 Anthropic 输出类型 {other}")),
|
||||
None => return Err("Anthropic content 缺少类型".into()),
|
||||
}
|
||||
}
|
||||
let reason = body
|
||||
.get("stop_reason")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Anthropic 响应缺少 stop_reason,无法确认完整完成")?;
|
||||
let (status, incomplete) = terminal(UpstreamApi::AnthropicMessages, Some(reason));
|
||||
Ok(envelope(
|
||||
&id,
|
||||
body.get("model").and_then(Value::as_str).unwrap_or(model),
|
||||
status,
|
||||
output,
|
||||
usage(UpstreamApi::AnthropicMessages, body.get("usage")),
|
||||
incomplete,
|
||||
))
|
||||
}
|
||||
|
||||
fn gemini_response(
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
if let Some(block) = body
|
||||
.pointer("/promptFeedback/blockReason")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return Err(format!("Gemini 拒绝了输入:{block}"));
|
||||
}
|
||||
let candidate = body
|
||||
.get("candidates")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|v| v.first())
|
||||
.ok_or("Gemini 响应缺少 candidates")?;
|
||||
let parts = candidate
|
||||
.pointer("/content/parts")
|
||||
.and_then(Value::as_array);
|
||||
if context.single_tool
|
||||
&& parts
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|part| part.get("functionCall").is_some())
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err("Gemini 返回了多个并行工具调用,而本次请求禁止并行;未向 Codex 返回任何可执行工具,请重试".into());
|
||||
}
|
||||
let id = response_id(body.get("responseId").and_then(Value::as_str));
|
||||
let mut output = Vec::new();
|
||||
let signed: Vec<Value> = parts
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter(|(_, part)| {
|
||||
part.get("thoughtSignature").is_some()
|
||||
&& part.get("thought").and_then(Value::as_bool) != Some(true)
|
||||
})
|
||||
.map(|(index, part)| {
|
||||
let mut part = part.clone();
|
||||
if part.get("text").is_some() {
|
||||
part["codex_item_id"] = json!(format!("msg_{id}_{index}"));
|
||||
}
|
||||
if part.get("functionCall").is_some() && part.pointer("/functionCall/id").is_none() {
|
||||
part["functionCall"]["id"] = json!(format!("call_{id}_{index}"));
|
||||
}
|
||||
part
|
||||
})
|
||||
.collect();
|
||||
if !signed.is_empty() {
|
||||
output.push(reasoning_item(
|
||||
&format!("rs_sig_{id}"),
|
||||
"",
|
||||
Some(json!({"protocol":"gemini","parts":signed})),
|
||||
));
|
||||
}
|
||||
for (index, part) in parts.into_iter().flatten().enumerate() {
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
output.push(
|
||||
if part.get("thought").and_then(Value::as_bool) == Some(true) {
|
||||
reasoning_item(
|
||||
&format!("rs_{id}_{index}"),
|
||||
text,
|
||||
Some(json!({"protocol":"gemini","parts":[part]})),
|
||||
)
|
||||
} else {
|
||||
text_item(&format!("msg_{id}_{index}"), text)
|
||||
},
|
||||
);
|
||||
} else if let Some(call) = part.get("functionCall") {
|
||||
let call_id = call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("call_{id}_{index}"));
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_{index}"),
|
||||
&call_id,
|
||||
call.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
&call
|
||||
.get("args")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}))
|
||||
.to_string(),
|
||||
)?);
|
||||
} else if part.get("thoughtSignature").is_none() {
|
||||
return Err("Gemini 返回了不能转换为 Codex 输出的多模态内容".into());
|
||||
}
|
||||
}
|
||||
let reason = candidate
|
||||
.get("finishReason")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Gemini 响应缺少 finishReason,无法确认完整完成")?;
|
||||
let (status, incomplete) = terminal(UpstreamApi::Gemini, Some(reason));
|
||||
Ok(envelope(
|
||||
&id,
|
||||
body.get("modelVersion")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(model),
|
||||
status,
|
||||
output,
|
||||
usage(UpstreamApi::Gemini, body.get("usageMetadata")),
|
||||
incomplete,
|
||||
))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
use super::*;
|
||||
use std::io::{self, Read};
|
||||
|
||||
fn prepared(api: UpstreamApi, body: Value) -> PreparedRequest {
|
||||
prepare(api, &serde_json::to_vec(&body).unwrap(), false).unwrap()
|
||||
}
|
||||
fn request(api: UpstreamApi, body: Value) -> Value {
|
||||
serde_json::from_slice(&prepared(api, body).bytes).unwrap()
|
||||
}
|
||||
fn events(text: &str) -> Vec<Value> {
|
||||
text.lines()
|
||||
.filter_map(|line| line.strip_prefix("data: "))
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ByteReader(Vec<u8>, usize);
|
||||
impl Read for ByteReader {
|
||||
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
|
||||
if self.1 == self.0.len() {
|
||||
return Ok(0);
|
||||
}
|
||||
output[0] = self.0[self.1];
|
||||
self.1 += 1;
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
fn streamed(api: UpstreamApi, input: &str, context: ToolContext) -> String {
|
||||
let mut reader = ResponseStream::new(
|
||||
ByteReader(input.as_bytes().to_vec(), 0),
|
||||
api,
|
||||
context,
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut text = String::new();
|
||||
reader.read_to_string(&mut text).unwrap();
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversions_preserve_roles_media_tools_and_parallel_results() {
|
||||
let body = json!({"model":"fixture","instructions":"system instruction","max_output_tokens":7000,"input":[
|
||||
{"role":"developer","content":"developer instruction"},
|
||||
{"role":"user","content":[{"type":"input_text","text":"inspect this"},{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]},
|
||||
{"type":"function_call","call_id":"call-1","namespace":"functions","name":"read","arguments":"{\"path\":\"x\"}"},
|
||||
{"type":"custom_tool_call","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch"},
|
||||
{"type":"function_call_output","call_id":"call-1","output":"file text"},
|
||||
{"type":"custom_tool_call_output","call_id":"call-2","output":[{"type":"input_text","text":"patched"},{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]}
|
||||
],"tools":[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"read","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}]},{"type":"custom","name":"apply_patch","description":"Apply a patch","format":{"type":"grammar","syntax":"lark","definition":"start: /.+/"}}]});
|
||||
let chat = request(UpstreamApi::ChatCompletions, body.clone());
|
||||
assert_eq!(chat["messages"][0]["role"], "system");
|
||||
assert_eq!(chat["messages"][1]["role"], "system");
|
||||
assert_eq!(
|
||||
chat["messages"][2]["content"][1]["image_url"]["url"],
|
||||
"data:image/png;base64,aGVsbG8="
|
||||
);
|
||||
assert_eq!(
|
||||
chat["messages"][3]["tool_calls"].as_array().unwrap().len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(chat["messages"][4]["tool_call_id"], "call-1");
|
||||
assert_eq!(chat["messages"][5]["tool_call_id"], "call-2");
|
||||
assert_eq!(chat["messages"][6]["role"], "user");
|
||||
assert!(chat["tools"][0]["function"]["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("functions__read_"));
|
||||
assert!(chat["tools"][1]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("grammar"));
|
||||
let anthropic = request(UpstreamApi::AnthropicMessages, body.clone());
|
||||
assert!(anthropic["system"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("developer instruction"));
|
||||
assert_eq!(anthropic["max_tokens"], 7000);
|
||||
assert_eq!(
|
||||
anthropic["messages"][0]["content"][1]["source"]["media_type"],
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
anthropic["messages"][1]["content"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
anthropic["messages"][2]["content"][1]["content"][1]["type"],
|
||||
"image"
|
||||
);
|
||||
let gemini = request(UpstreamApi::Gemini, body);
|
||||
assert_eq!(
|
||||
gemini["systemInstruction"]["parts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][0]["parts"][1]["inlineData"]["mimeType"],
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][1]["parts"][0]["functionCall"]["id"],
|
||||
"call-1"
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][2]["parts"][0]["functionResponse"]["id"],
|
||||
"call-1"
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][2]["parts"][0]["functionResponse"]["name"],
|
||||
gemini["contents"][1]["parts"][0]["functionCall"]["name"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_responses_server_state_is_explicit_and_native_bytes_are_unchanged() {
|
||||
for api in [
|
||||
UpstreamApi::ChatCompletions,
|
||||
UpstreamApi::AnthropicMessages,
|
||||
UpstreamApi::Gemini,
|
||||
] {
|
||||
for body in [
|
||||
json!({"model":"fixture","input":"x","previous_response_id":"resp-remote"}),
|
||||
json!({"model":"fixture","input":[{"type":"reasoning","encrypted_content":"opaque-upstream-ciphertext"}]}),
|
||||
json!({"model":"fixture","input":[{"type":"item_reference","id":"remote-item"}]}),
|
||||
] {
|
||||
let bytes = serde_json::to_vec(&body).unwrap();
|
||||
assert!(prepare(api, &bytes, false).is_err());
|
||||
assert_eq!(
|
||||
prepare(UpstreamApi::Responses, &bytes, false)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
bytes
|
||||
);
|
||||
}
|
||||
assert!(prepare(api, br#"{"model":"fixture","input":"x"}"#, true)
|
||||
.unwrap_err()
|
||||
.contains("compact"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_and_custom_tools_restore_original_codex_shapes() {
|
||||
let prepared = prepared(
|
||||
UpstreamApi::ChatCompletions,
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"read"}]},{"type":"custom","name":"apply_patch"}]}),
|
||||
);
|
||||
let name = prepared.context.upstream_name("read", Some("functions"));
|
||||
let body = json!({"id":"chat-1","choices":[{"finish_reason":"tool_calls","message":{"tool_calls":[{"id":"a","function":{"name":name,"arguments":"{}"}},{"id":"b","function":{"name":"apply_patch","arguments":"{\"input\":\"patch text\"}"}}]}}],"usage":{"prompt_tokens":3,"completion_tokens":4}});
|
||||
let converted = convert_response(
|
||||
UpstreamApi::ChatCompletions,
|
||||
&body,
|
||||
&prepared.context,
|
||||
"fixture",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(converted["output"][0]["namespace"], "functions");
|
||||
assert_eq!(converted["output"][0]["name"], "read");
|
||||
assert_eq!(converted["output"][1]["type"], "custom_tool_call");
|
||||
assert_eq!(converted["output"][1]["input"], "patch text");
|
||||
assert_eq!(converted["usage"]["total_tokens"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_sse_crosses_utf8_crlf_and_fragmented_function_json() {
|
||||
let context = prepared(
|
||||
UpstreamApi::ChatCompletions,
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"function","name":"read"}]}),
|
||||
)
|
||||
.context;
|
||||
let input=concat!("data: {\"id\":\"c\",\"choices\":[{\"delta\":{\"content\":\"中文🙂\"}}]}\r\n\r\n", "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-x\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"x\\\":\"}}]}}]}\r\n\r\n", "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\r\n\r\n", "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\r\n\r\n", "data: [DONE]\r\n\r\n");
|
||||
let output = streamed(UpstreamApi::ChatCompletions, input, context);
|
||||
let events = events(&output);
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|v| v["type"] == "response.output_text.delta" && v["delta"] == "中文🙂"));
|
||||
let response = &events.last().unwrap()["response"];
|
||||
assert_eq!(response["status"], "completed");
|
||||
assert_eq!(response["output"][1]["call_id"], "call-x");
|
||||
assert_eq!(response["output"][1]["arguments"], "{\"x\":1}");
|
||||
assert_eq!(response["usage"]["total_tokens"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_error_before_output_returns_error_and_after_output_emits_failed() {
|
||||
let error = "event: error\ndata: {\"error\":{\"message\":\"fixture outage\"}}\n\n";
|
||||
let mut early = ResponseStream::new(
|
||||
io::Cursor::new(error),
|
||||
UpstreamApi::ChatCompletions,
|
||||
ToolContext::default(),
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
assert!(early.read_to_end(&mut bytes).is_err());
|
||||
assert!(bytes.is_empty());
|
||||
let output = streamed(
|
||||
UpstreamApi::ChatCompletions,
|
||||
&format!("data: {{\"choices\":[{{\"delta\":{{\"content\":\"partial\"}}}}]}}\n\n{error}"),
|
||||
ToolContext::default(),
|
||||
);
|
||||
let events = events(&output);
|
||||
assert_eq!(events.last().unwrap()["type"], "response.failed");
|
||||
assert!(events.iter().all(|v| v["type"] != "response.completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_eof_without_terminal_signal_is_incomplete() {
|
||||
for (api, input) in [
|
||||
(
|
||||
UpstreamApi::ChatCompletions,
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n",
|
||||
),
|
||||
(
|
||||
UpstreamApi::Gemini,
|
||||
"data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"partial\"}]}}]}\n\n",
|
||||
),
|
||||
] {
|
||||
let output = streamed(api, input, ToolContext::default());
|
||||
let events = events(&output);
|
||||
assert_eq!(events.last().unwrap()["type"], "response.incomplete");
|
||||
assert_eq!(
|
||||
events.last().unwrap()["response"]["incomplete_details"]["reason"],
|
||||
"stream_truncated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_thinking_signature_and_gemini_tool_signature_round_trip() {
|
||||
let ctx = prepared(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"function","name":"read"}]}),
|
||||
)
|
||||
.context;
|
||||
let anthropic = json!({"id":"m","type":"message","content":[{"type":"thinking","thinking":"analysis","signature":"signed-thinking"},{"type":"tool_use","id":"call-a","name":"read","input":{}}],"stop_reason":"tool_use","usage":{"input_tokens":2,"output_tokens":3,"cache_read_input_tokens":5}});
|
||||
let response =
|
||||
convert_response(UpstreamApi::AnthropicMessages, &anthropic, &ctx, "fixture").unwrap();
|
||||
assert_eq!(response["usage"]["input_tokens"], 7);
|
||||
let mut history = vec![json!({"role":"user","content":"inspect"})];
|
||||
history.extend(response["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"type":"function_call_output","call_id":"call-a","output":"done"}));
|
||||
let anthropic_req = request(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
json!({"model":"fixture","input":history}),
|
||||
);
|
||||
assert_eq!(
|
||||
anthropic_req["messages"][1]["content"][0]["signature"],
|
||||
"signed-thinking"
|
||||
);
|
||||
let gemini = json!({"responseId":"g","candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{}},"thoughtSignature":"signed-function"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":2,"candidatesTokenCount":3,"thoughtsTokenCount":4}});
|
||||
let response = convert_response(UpstreamApi::Gemini, &gemini, &ctx, "fixture").unwrap();
|
||||
let call = response["output"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["type"] == "function_call")
|
||||
.unwrap();
|
||||
let mut history = vec![json!({"role":"user","content":"inspect"})];
|
||||
history.extend(response["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"type":"function_call_output","call_id":call["call_id"],"output":"done"}));
|
||||
let request = request(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"fixture","input":history}),
|
||||
);
|
||||
assert_eq!(request["contents"][1]["parts"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
request["contents"][1]["parts"][0]["thoughtSignature"],
|
||||
"signed-function"
|
||||
);
|
||||
assert_eq!(response["usage"]["output_tokens"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_json_fallback_and_length_reason_are_truthful() {
|
||||
let input = r#"{"id":"chat","choices":[{"message":{"content":"truncated"},"finish_reason":"length"}],"usage":{"prompt_tokens":2,"completion_tokens":3}}"#;
|
||||
let output = streamed(UpstreamApi::ChatCompletions, input, ToolContext::default());
|
||||
let events = events(&output);
|
||||
assert_eq!(events.last().unwrap()["type"], "response.incomplete");
|
||||
assert_eq!(
|
||||
events.last().unwrap()["response"]["incomplete_details"]["reason"],
|
||||
"max_output_tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_signed_text_chunks_replay_on_the_matching_assistant_message() {
|
||||
let input="data: {\"responseId\":\"g\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello \"}]}}]}\n\ndata: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"world\",\"thoughtSignature\":\"signature\"}]},\"finishReason\":\"STOP\"}]}\n\n";
|
||||
let output = streamed(UpstreamApi::Gemini, input, ToolContext::default());
|
||||
let ev = events(&output);
|
||||
let response = &ev.last().unwrap()["response"];
|
||||
let mut history = vec![json!({"role":"user","content":"hello world"})];
|
||||
history.extend(response["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"role":"user","content":"continue"}));
|
||||
let converted = request(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"gemini-2.5-pro","input":history}),
|
||||
);
|
||||
assert!(converted["contents"][0]["parts"][0]
|
||||
.get("thoughtSignature")
|
||||
.is_none());
|
||||
assert_eq!(converted["contents"][1]["parts"][0]["text"], "hello world");
|
||||
assert_eq!(
|
||||
converted["contents"][1]["parts"][0]["thoughtSignature"],
|
||||
"signature"
|
||||
);
|
||||
let mut history = vec![json!({"role":"user","content":"OK"})];
|
||||
for (id, sig) in [("g1", "sig1"), ("g2", "sig2")] {
|
||||
let converted=convert_response(UpstreamApi::Gemini,&json!({"responseId":id,"candidates":[{"content":{"parts":[{"text":"OK","thoughtSignature":sig}]},"finishReason":"STOP"}]}),&ToolContext::default(),"fixture").unwrap();
|
||||
history.extend(converted["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"role":"user","content":"OK"}));
|
||||
}
|
||||
let converted = request(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"fixture","input":history}),
|
||||
);
|
||||
assert_eq!(
|
||||
converted["contents"][1]["parts"][0]["thoughtSignature"],
|
||||
"sig1"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["contents"][3]["parts"][0]["thoughtSignature"],
|
||||
"sig2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_single_tool_constraint_buffers_tools_and_rejects_parallel_execution() {
|
||||
let prepared = prepared(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"gemini-2.5-pro","input":"x","parallel_tool_calls":false,"tools":[{"type":"function","name":"read"}]}),
|
||||
);
|
||||
let request: Value = serde_json::from_slice(&prepared.bytes).unwrap();
|
||||
assert!(request["systemInstruction"]["parts"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("at most one"));
|
||||
let response = json!({"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{}}},{"functionCall":{"name":"read","args":{}}}]},"finishReason":"STOP"}]});
|
||||
assert!(
|
||||
convert_response(UpstreamApi::Gemini, &response, &prepared.context, "fixture").is_err()
|
||||
);
|
||||
let input = format!("data: {response}\n\n");
|
||||
let mut stream = ResponseStream::new(
|
||||
ByteReader(input.into_bytes(), 0),
|
||||
UpstreamApi::Gemini,
|
||||
prepared.context.clone(),
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
assert!(stream.read_to_end(&mut bytes).is_err());
|
||||
assert!(bytes.is_empty());
|
||||
let input=format!("data: {{\"candidates\":[{{\"content\":{{\"parts\":[{{\"text\":\"planning\"}}]}}}}]}}\n\ndata: {response}\n\n");
|
||||
let output = streamed(UpstreamApi::Gemini, &input, prepared.context.clone());
|
||||
let ev = events(&output);
|
||||
assert_eq!(ev.last().unwrap()["type"], "response.failed");
|
||||
assert!(ev
|
||||
.iter()
|
||||
.all(|v| v["type"] != "response.output_item.done" || v["item"]["type"] != "function_call"));
|
||||
let single="data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"read\",\"args\":{}}}]},\"finishReason\":\"STOP\"}]}\n\n";
|
||||
let output = streamed(UpstreamApi::Gemini, single, prepared.context);
|
||||
assert_eq!(
|
||||
events(&output).last().unwrap()["type"],
|
||||
"response.completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_parallel_results_keep_media_after_all_tool_responses_and_reasoning_on_call() {
|
||||
let body = json!({"model":"fixture","input":[{"role":"user","content":"x"},{"type":"reasoning","summary":[{"text":"analysis"}]},{"role":"assistant","content":"Let me check"},{"type":"function_call","call_id":"a","name":"read","arguments":"{}"},{"type":"function_call","call_id":"b","name":"read","arguments":"{}"},{"type":"function_call_output","call_id":"a","output":[{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]},{"type":"function_call_output","call_id":"b","output":"done"}]});
|
||||
let converted = request(UpstreamApi::ChatCompletions, body);
|
||||
let messages = converted["messages"].as_array().unwrap();
|
||||
assert_eq!(messages[1]["reasoning_content"], "analysis");
|
||||
assert_eq!(messages[1]["content"][0]["text"], "Let me check");
|
||||
assert_eq!(messages[1]["tool_calls"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(messages[2]["tool_call_id"], "a");
|
||||
assert_eq!(messages[3]["tool_call_id"], "b");
|
||||
assert_eq!(messages[4]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_and_unsupported_parameters_are_explicit() {
|
||||
let body = json!({"model":"fixture","input":"x","text":{"format":{"type":"json_schema","name":"test","strict":true,"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}});
|
||||
let chat = request(UpstreamApi::ChatCompletions, body.clone());
|
||||
assert_eq!(chat["response_format"]["json_schema"]["strict"], true);
|
||||
let anth = request(UpstreamApi::AnthropicMessages, body.clone());
|
||||
assert_eq!(anth["output_config"]["format"]["schema"]["type"], "object");
|
||||
let gem = request(UpstreamApi::Gemini, body);
|
||||
assert_eq!(
|
||||
gem["generationConfig"]["responseMimeType"],
|
||||
"application/json"
|
||||
);
|
||||
assert_eq!(
|
||||
gem["generationConfig"]["responseJsonSchema"]["type"],
|
||||
"object"
|
||||
);
|
||||
for body in [
|
||||
json!({"model":"fixture","input":"x","unknown_setting":1}),
|
||||
json!({"model":"fixture","input":"x","tools":{}}),
|
||||
json!({"model":"fixture","input":"x","tool_choice":"required"}),
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"function","name":"read"},{"type":"function","name":"read"}]}),
|
||||
] {
|
||||
assert!(prepare(
|
||||
UpstreamApi::ChatCompletions,
|
||||
&serde_json::to_vec(&body).unwrap(),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
assert!(prepare(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
&serde_json::to_vec(
|
||||
&json!({"model":"fixture","input":"x","temperature":0.2,"reasoning":{"effort":"high"}})
|
||||
)
|
||||
.unwrap(),
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("temperature"));
|
||||
let ultra = request(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
json!({"model":"fixture","input":"x","max_output_tokens":80000,"reasoning":{"effort":"ultra"}}),
|
||||
);
|
||||
assert_eq!(ultra["thinking"]["budget_tokens"], 32768);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_sse_does_not_consume_late_error_or_transport_reset() {
|
||||
struct ResetAfter(Vec<Vec<u8>>, usize);
|
||||
impl Read for ResetAfter {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||
if self.1 == self.0.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::ConnectionReset, "late reset"));
|
||||
}
|
||||
let chunk = &self.0[self.1];
|
||||
bytes[..chunk.len()].copy_from_slice(chunk);
|
||||
self.1 += 1;
|
||||
Ok(chunk.len())
|
||||
}
|
||||
}
|
||||
let chunks = vec![
|
||||
b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n".to_vec(),
|
||||
b"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
|
||||
.to_vec(),
|
||||
b"event: error\ndata: {\"error\":{\"message\":\"late\"}}\n\n".to_vec(),
|
||||
];
|
||||
let mut stream = ResponseStream::new(
|
||||
ResetAfter(chunks, 0),
|
||||
UpstreamApi::ChatCompletions,
|
||||
ToolContext::default(),
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut output = String::new();
|
||||
stream.read_to_string(&mut output).unwrap();
|
||||
let ev = events(&output);
|
||||
assert_eq!(
|
||||
ev.iter()
|
||||
.filter(|v| v["type"] == "response.completed")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(ev.iter().all(|v| v["type"] != "response.failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_stream_fallback_has_empty_added_items_before_deltas() {
|
||||
let input = r#"{"id":"chat","choices":[{"message":{"content":"hello","tool_calls":[{"id":"a","function":{"name":"read","arguments":"{\"x\":1}"}}]},"finish_reason":"tool_calls"}]}"#;
|
||||
let output = streamed(UpstreamApi::ChatCompletions, input, ToolContext::default());
|
||||
let ev = events(&output);
|
||||
let added = ev
|
||||
.iter()
|
||||
.filter(|v| v["type"] == "response.output_item.added")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(added[0]["item"]["status"], "in_progress");
|
||||
assert_eq!(added[0]["item"]["content"], json!([]));
|
||||
assert_eq!(added[1]["item"]["arguments"], "");
|
||||
let part = ev
|
||||
.iter()
|
||||
.find(|v| v["type"] == "response.content_part.added")
|
||||
.unwrap();
|
||||
assert_eq!(part["part"]["text"], "");
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
use super::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
|
||||
use super::config::RoutingTuning;
|
||||
use super::native_official::{OfficialRequestAuth, OfficialRouteSpec};
|
||||
use super::protocol::{self, UpstreamApi};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -35,6 +36,7 @@ pub(crate) struct ProxyRoute {
|
||||
pub(crate) headers: Vec<(String, String)>,
|
||||
pub(crate) models: HashSet<String>,
|
||||
pub(crate) official: Option<OfficialRouteSpec>,
|
||||
pub(crate) protocol: UpstreamApi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
@@ -435,6 +437,7 @@ fn reject_self_routes(routes: &[ProxyRoute], address: IpAddr, port: u16) -> Resu
|
||||
|
||||
fn same_transport(left: &ProxyRoute, right: &ProxyRoute) -> bool {
|
||||
left.base_url == right.base_url
|
||||
&& left.protocol == right.protocol
|
||||
&& left.api_key == right.api_key
|
||||
&& left.headers == right.headers
|
||||
&& left.models == right.models
|
||||
@@ -1026,7 +1029,64 @@ fn upstream_url(route: &ProxyRoute, data: &RequestData) -> Option<String> {
|
||||
let base_path = url.path().trim_end_matches('/');
|
||||
// Saved base_url is the API prefix, matching Codex's own /responses join.
|
||||
// Do not invent /v1 for vendor endpoints that deliberately omit it.
|
||||
url.set_path(&format!("{base_path}/{}", data.suffix));
|
||||
let path = match route.protocol {
|
||||
UpstreamApi::Responses => format!("{base_path}/{}", data.suffix),
|
||||
UpstreamApi::ChatCompletions => {
|
||||
if base_path.ends_with("/chat/completions") {
|
||||
base_path.to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{}/chat/completions",
|
||||
if base_path.is_empty() {
|
||||
"/v1"
|
||||
} else {
|
||||
base_path
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
UpstreamApi::AnthropicMessages => {
|
||||
if base_path.ends_with("/messages") {
|
||||
base_path.to_owned()
|
||||
} else if base_path.ends_with("/v1") {
|
||||
format!("{base_path}/messages")
|
||||
} else {
|
||||
format!("{base_path}/v1/messages")
|
||||
}
|
||||
}
|
||||
UpstreamApi::Gemini => {
|
||||
let model = data
|
||||
.model
|
||||
.as_deref()?
|
||||
.strip_prefix("models/")
|
||||
.unwrap_or(data.model.as_deref()?);
|
||||
if model.is_empty() || model.contains('/') || model.contains(':') {
|
||||
return None;
|
||||
}
|
||||
let model =
|
||||
percent_encoding::utf8_percent_encode(model, percent_encoding::NON_ALPHANUMERIC)
|
||||
.to_string();
|
||||
let method = if data.streaming {
|
||||
"streamGenerateContent"
|
||||
} else {
|
||||
"generateContent"
|
||||
};
|
||||
if base_path.contains("/models/") {
|
||||
let (prefix, _) = base_path.rsplit_once("/models/")?;
|
||||
format!("{prefix}/models/{model}:{method}")
|
||||
} else {
|
||||
format!(
|
||||
"{}/models/{model}:{method}",
|
||||
if base_path.is_empty() {
|
||||
"/v1beta"
|
||||
} else {
|
||||
base_path
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
url.set_path(&path);
|
||||
let query = match (url.query(), data.query.as_deref()) {
|
||||
(Some(base), Some(request)) if !base.is_empty() && !request.is_empty() => {
|
||||
Some(format!("{base}&{request}"))
|
||||
@@ -1036,6 +1096,9 @@ fn upstream_url(route: &ProxyRoute, data: &RequestData) -> Option<String> {
|
||||
(None, None) => None,
|
||||
};
|
||||
url.set_query(query.as_deref());
|
||||
if route.protocol == UpstreamApi::Gemini && data.streaming {
|
||||
url.query_pairs_mut().append_pair("alt", "sse");
|
||||
}
|
||||
Some(url.into())
|
||||
}
|
||||
|
||||
@@ -1108,6 +1171,7 @@ struct UpstreamResponse {
|
||||
headers: reqwest::header::HeaderMap,
|
||||
deadline: Option<Instant>,
|
||||
next_timeout: Option<Duration>,
|
||||
idle_timeout: Option<Duration>,
|
||||
pending: Vec<u8>,
|
||||
offset: usize,
|
||||
}
|
||||
@@ -1123,6 +1187,7 @@ impl Read for UpstreamResponse {
|
||||
let count = bytes.len().min(self.pending.len() - self.offset);
|
||||
bytes[..count].copy_from_slice(&self.pending[self.offset..self.offset + count]);
|
||||
self.offset += count;
|
||||
self.next_timeout = self.idle_timeout;
|
||||
return Ok(count);
|
||||
}
|
||||
let deadline = match (self.deadline, idle_deadline) {
|
||||
@@ -1167,6 +1232,7 @@ fn request_upstream(
|
||||
data: &RequestData,
|
||||
timing: RequestTimeouts,
|
||||
official: Option<&OfficialRequestAuth>,
|
||||
body: &[u8],
|
||||
) -> std::result::Result<UpstreamResponse, ()> {
|
||||
let mut effective = route.clone();
|
||||
if let Some(auth) = official {
|
||||
@@ -1216,11 +1282,61 @@ fn request_upstream(
|
||||
reqwest::header::HeaderValue::from_str(account).map_err(|_| ())?,
|
||||
);
|
||||
}
|
||||
} else if let Some(key) = route.api_key.as_deref().filter(|key| !key.is_empty()) {
|
||||
let mut value =
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")).map_err(|_| ())?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert(reqwest::header::AUTHORIZATION, value);
|
||||
} else {
|
||||
// Supplier credentials belong to its route snapshot. Neither local
|
||||
// router authentication nor another protocol's default key is reused.
|
||||
for name in [
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"chatgpt-account-id",
|
||||
] {
|
||||
headers.remove(name);
|
||||
}
|
||||
if route.protocol == UpstreamApi::AnthropicMessages {
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
reqwest::header::HeaderValue::from_static("2023-06-01"),
|
||||
);
|
||||
}
|
||||
let custom_auth = route.headers.iter().any(|(name, _)| {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"authorization" | "x-api-key" | "x-goog-api-key"
|
||||
)
|
||||
});
|
||||
if let Some(key) = route
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.is_empty() && !custom_auth)
|
||||
{
|
||||
let (name, text) = match route.protocol {
|
||||
UpstreamApi::Responses | UpstreamApi::ChatCompletions => {
|
||||
("authorization", format!("Bearer {key}"))
|
||||
}
|
||||
UpstreamApi::AnthropicMessages => ("x-api-key", key.to_owned()),
|
||||
UpstreamApi::Gemini => ("x-goog-api-key", key.to_owned()),
|
||||
};
|
||||
let mut value = reqwest::header::HeaderValue::from_str(&text).map_err(|_| ())?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert(
|
||||
reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| ())?,
|
||||
value,
|
||||
);
|
||||
}
|
||||
// Explicit supplier headers override defaults. Client headers are
|
||||
// restricted independently and cannot override supplier credentials.
|
||||
for (name, value) in &route.headers {
|
||||
if !forbidden_upstream_header(name)
|
||||
&& !name.eq_ignore_ascii_case(super::config::ROUTE_TOKEN_HEADER)
|
||||
&& !name.eq_ignore_ascii_case(super::config::ROUTE_GENERATION_HEADER)
|
||||
{
|
||||
headers.insert(
|
||||
reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| ())?,
|
||||
reqwest::header::HeaderValue::from_str(value).map_err(|_| ())?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
@@ -1241,7 +1357,7 @@ fn request_upstream(
|
||||
let request = if data.model.is_none() {
|
||||
client.get(&url)
|
||||
} else {
|
||||
client.post(&url).body(data.bytes.clone())
|
||||
client.post(&url).body(body.to_vec())
|
||||
}
|
||||
.headers(headers);
|
||||
let started = Instant::now();
|
||||
@@ -1275,6 +1391,7 @@ fn request_upstream(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
idle_timeout: if data.streaming { timing.idle } else { None },
|
||||
pending: Vec::new(),
|
||||
offset: 0,
|
||||
})
|
||||
@@ -1328,6 +1445,82 @@ fn buffered_response(mut response: UpstreamResponse) -> io::Result<BufferedRespo
|
||||
})
|
||||
}
|
||||
|
||||
fn bridge_buffered(
|
||||
mut response: BufferedResponse,
|
||||
route: &ProxyRoute,
|
||||
prepared: &protocol::PreparedRequest,
|
||||
) -> io::Result<BufferedResponse> {
|
||||
if route.protocol == UpstreamApi::Responses {
|
||||
return Ok(response);
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&response.bytes)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "上游响应不是有效的 JSON"))?;
|
||||
if response.status >= 300 {
|
||||
let message = protocol::error_message(&body).unwrap_or_else(|| body.to_string());
|
||||
response.bytes = serde_json::to_vec(
|
||||
&json!({"error":{"type":"upstream_error","code":"upstream_error","message":message}}),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
} else {
|
||||
let body =
|
||||
protocol::convert_response(route.protocol, &body, &prepared.context, &prepared.model)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
response.bytes = serde_json::to_vec(&body).map_err(io::Error::other)?;
|
||||
}
|
||||
response
|
||||
.headers
|
||||
.retain(|(name, _)| !name.eq_ignore_ascii_case("content-type"));
|
||||
response
|
||||
.headers
|
||||
.push(("content-type".into(), "application/json".into()));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn bridge_nonstream_sse(
|
||||
response: UpstreamResponse,
|
||||
route: &ProxyRoute,
|
||||
prepared: &protocol::PreparedRequest,
|
||||
) -> io::Result<BufferedResponse> {
|
||||
let mut headers = downstream_headers(&response);
|
||||
let mut reader = protocol::ResponseStream::new(
|
||||
response,
|
||||
route.protocol,
|
||||
prepared.context.clone(),
|
||||
prepared.model.clone(),
|
||||
);
|
||||
let bytes = read_bounded(&mut reader, MAX_BODY_BYTES)?;
|
||||
let text = std::str::from_utf8(&bytes)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "转换后的 SSE 不是 UTF-8"))?;
|
||||
let event = text
|
||||
.lines()
|
||||
.rev()
|
||||
.filter_map(|line| line.strip_prefix("data: "))
|
||||
.filter_map(|data| serde_json::from_str::<Value>(data).ok())
|
||||
.find(|v| {
|
||||
matches!(
|
||||
v.get("type").and_then(Value::as_str),
|
||||
Some("response.completed" | "response.incomplete" | "response.failed")
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "上游 SSE 缺少完成事件"))?;
|
||||
if event["type"] == "response.failed" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
event
|
||||
.pointer("/response/error/message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("上游 SSE 失败"),
|
||||
));
|
||||
}
|
||||
headers.retain(|(name, _)| !name.eq_ignore_ascii_case("content-type"));
|
||||
headers.push(("content-type".into(), "application/json".into()));
|
||||
Ok(BufferedResponse {
|
||||
status: 200,
|
||||
headers,
|
||||
bytes: serde_json::to_vec(&event["response"]).map_err(io::Error::other)?,
|
||||
})
|
||||
}
|
||||
|
||||
struct Permit {
|
||||
breaker: Option<Arc<CircuitBreaker>>,
|
||||
half_open: bool,
|
||||
@@ -1520,6 +1713,17 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if data.model.is_none()
|
||||
&& config
|
||||
.routes
|
||||
.first()
|
||||
.is_some_and(|route| route.protocol.requires_routing())
|
||||
{
|
||||
let mut models: Vec<_> = config.routes[0].models.iter().cloned().collect();
|
||||
models.sort();
|
||||
respond_buffered(request,BufferedResponse {status:200,headers:vec![("content-type".into(),"application/json".into())],bytes:serde_json::to_vec(&json!({"object":"list","data":models.into_iter().map(|id|json!({"id":id,"object":"model","owned_by":"supplier"})).collect::<Vec<_>>()})).unwrap_or_default()});
|
||||
return;
|
||||
}
|
||||
let automatic = config.options.auto_failover_enabled && !official;
|
||||
let max_attempts = if automatic {
|
||||
config.options.tuning.max_retries.saturating_add(1) as usize
|
||||
@@ -1529,6 +1733,7 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
let timing = timeouts(shared, &config.options, official);
|
||||
let mut attempted = 0;
|
||||
let mut last_response = None;
|
||||
let mut last_bridge_error = None;
|
||||
for (index, route) in config.routes.iter().enumerate() {
|
||||
if shared.stopped.load(Ordering::Acquire) {
|
||||
fail_request(shared);
|
||||
@@ -1541,11 +1746,37 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
if index > 0 && (!automatic || data.primary_only) {
|
||||
break;
|
||||
}
|
||||
let prepared = match if data.model.is_none() {
|
||||
Ok(protocol::PreparedRequest {
|
||||
bytes: data.bytes.clone(),
|
||||
context: protocol::ToolContext::default(),
|
||||
model: String::new(),
|
||||
})
|
||||
} else {
|
||||
protocol::prepare(
|
||||
route.protocol,
|
||||
&data.bytes,
|
||||
data.suffix == "responses/compact",
|
||||
)
|
||||
} {
|
||||
Ok(prepared) => prepared,
|
||||
Err(message) => {
|
||||
fail_request(shared);
|
||||
respond_error(request, 400, &message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(mut permit) = Permit::acquire(&breakers, route, automatic) else {
|
||||
continue;
|
||||
};
|
||||
attempted += 1;
|
||||
let mut response = match request_upstream(route, &data, timing, official_auth.as_ref()) {
|
||||
let response = match request_upstream(
|
||||
route,
|
||||
&data,
|
||||
timing,
|
||||
official_auth.as_ref(),
|
||||
&prepared.bytes,
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(()) => {
|
||||
set_attempt_failure(
|
||||
@@ -1572,7 +1803,7 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
Some(status),
|
||||
"供应商暂时不可用",
|
||||
);
|
||||
last_response = Some(response);
|
||||
last_response = Some((response, route.clone(), prepared));
|
||||
continue;
|
||||
}
|
||||
if (300..=399).contains(&status) {
|
||||
@@ -1595,7 +1826,9 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
if let Some(health) = lock(&shared.statistics).health.get_mut(&route.id) {
|
||||
health.last_status = Some(status);
|
||||
}
|
||||
match buffered_response(response) {
|
||||
match buffered_response(response)
|
||||
.and_then(|response| bridge_buffered(response, route, &prepared))
|
||||
{
|
||||
Ok(response) => respond_buffered(request, response),
|
||||
Err(_) => respond_error(request, 502, "供应商响应无法读取"),
|
||||
};
|
||||
@@ -1614,11 +1847,49 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("text/event-stream")
|
||||
});
|
||||
if !data.streaming && streaming && route.protocol.requires_routing() {
|
||||
match bridge_nonstream_sse(response, route, &prepared) {
|
||||
Ok(response) => {
|
||||
set_success(shared, &config, route, &mut permit, index, status);
|
||||
respond_buffered(request, response);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
last_bridge_error = Some(error.to_string());
|
||||
set_attempt_failure(
|
||||
shared,
|
||||
&config,
|
||||
route,
|
||||
&mut permit,
|
||||
Some(status),
|
||||
"供应商 SSE 返回无法转换为完整 JSON",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if streaming {
|
||||
let headers = downstream_headers(&response);
|
||||
let mut headers = downstream_headers(&response);
|
||||
if route.protocol.requires_routing() {
|
||||
headers.retain(|(name, _)| !name.eq_ignore_ascii_case("content-type"));
|
||||
headers.push(("content-type".into(), "text/event-stream".into()));
|
||||
}
|
||||
let mut bridge_failure = None;
|
||||
let mut reader: Box<dyn Read + Send + Sync> = if route.protocol.requires_routing() {
|
||||
let converted = protocol::ResponseStream::new(
|
||||
response,
|
||||
route.protocol,
|
||||
prepared.context,
|
||||
prepared.model,
|
||||
);
|
||||
bridge_failure = Some(converted.failure_flag());
|
||||
Box::new(converted)
|
||||
} else {
|
||||
Box::new(response)
|
||||
};
|
||||
let mut first = [0; 16 * 1024];
|
||||
let count = match response.read(&mut first) {
|
||||
Ok(0) | Err(_) => {
|
||||
let count = match reader.read(&mut first) {
|
||||
Ok(0) => {
|
||||
set_attempt_failure(
|
||||
shared,
|
||||
&config,
|
||||
@@ -1632,20 +1903,31 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
if route.protocol.requires_routing() {
|
||||
last_bridge_error = Some(error.to_string());
|
||||
}
|
||||
set_attempt_failure(
|
||||
shared,
|
||||
&config,
|
||||
route,
|
||||
&mut permit,
|
||||
Some(status),
|
||||
"供应商首包读取或协议转换失败",
|
||||
);
|
||||
if official {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(count) => count,
|
||||
};
|
||||
response.next_timeout = timing.idle;
|
||||
set_success(shared, &config, route, &mut permit, index, status);
|
||||
if matches!(
|
||||
stream_response(
|
||||
request,
|
||||
status,
|
||||
headers,
|
||||
&first[..count],
|
||||
Box::new(response)
|
||||
),
|
||||
stream_response(request, status, headers, &first[..count], reader),
|
||||
StreamOutcome::UpstreamFailure
|
||||
) {
|
||||
) || bridge_failure.is_some_and(|flag| flag.load(Ordering::Acquire))
|
||||
{
|
||||
// Like CC Switch, after the committed first chunk this is a
|
||||
// downstream stream failure, never a fresh provider attempt.
|
||||
let mut stats = lock(&shared.statistics);
|
||||
@@ -1653,13 +1935,18 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
match buffered_response(response) {
|
||||
match buffered_response(response)
|
||||
.and_then(|response| bridge_buffered(response, route, &prepared))
|
||||
{
|
||||
Ok(response) => {
|
||||
set_success(shared, &config, route, &mut permit, index, status);
|
||||
respond_buffered(request, response);
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
if route.protocol.requires_routing() {
|
||||
last_bridge_error = Some(error.to_string());
|
||||
}
|
||||
set_attempt_failure(
|
||||
shared,
|
||||
&config,
|
||||
@@ -1675,11 +1962,15 @@ fn handle_request(mut request: Request, shared: &Arc<Shared>) {
|
||||
}
|
||||
}
|
||||
fail_request(shared);
|
||||
if let Some(response) = last_response {
|
||||
match buffered_response(response) {
|
||||
if let Some((response, route, prepared)) = last_response {
|
||||
match buffered_response(response)
|
||||
.and_then(|response| bridge_buffered(response, &route, &prepared))
|
||||
{
|
||||
Ok(response) => respond_buffered(request, response),
|
||||
Err(_) => respond_error(request, 502, "暂时无法连接可用供应商,请稍后重试"),
|
||||
}
|
||||
} else if let Some(error) = last_bridge_error {
|
||||
respond_error(request, 502, &format!("上游协议转换失败:{error}"));
|
||||
} else if attempted > 0 {
|
||||
respond_error(request, 502, "暂时无法连接可用供应商,请稍后重试");
|
||||
} else {
|
||||
@@ -1928,6 +2219,7 @@ mod tests {
|
||||
headers: Vec::new(),
|
||||
models: HashSet::from([MODEL.to_string()]),
|
||||
official: None,
|
||||
protocol: UpstreamApi::Responses,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1935,6 +2227,180 @@ mod tests {
|
||||
ProxyHandle::start_with_timeout(0, TOKEN.into(), routes, Duration::from_secs(2)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_bridges_use_correct_endpoints_auth_and_codex_json() {
|
||||
for (api, reply) in [
|
||||
(
|
||||
UpstreamApi::ChatCompletions,
|
||||
r#"{"id":"chat","model":"fixture-model","choices":[{"message":{"content":"chat reply"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":3}}"#,
|
||||
),
|
||||
(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
r#"{"id":"anth","model":"fixture-model","type":"message","content":[{"type":"text","text":"anthropic reply"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":3}}"#,
|
||||
),
|
||||
(
|
||||
UpstreamApi::Gemini,
|
||||
r#"{"responseId":"gem","candidates":[{"content":{"parts":[{"text":"gemini reply"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":2,"candidatesTokenCount":3}}"#,
|
||||
),
|
||||
] {
|
||||
let upstream = Upstream::json(200, reply);
|
||||
let mut supplier = route("converted", &upstream, "fixture-secret");
|
||||
supplier.protocol = api;
|
||||
let proxy = start(vec![supplier]);
|
||||
let body: Value =
|
||||
serde_json::from_str(&request(&proxy, &payload()).into_string().unwrap()).unwrap();
|
||||
assert_eq!(body["object"], "response");
|
||||
assert_eq!(body["status"], "completed");
|
||||
assert_eq!(body["usage"]["total_tokens"], 5);
|
||||
let calls = upstream.observed();
|
||||
let seen = &calls[0];
|
||||
let converted: Value = serde_json::from_slice(&seen.body).unwrap();
|
||||
match api {
|
||||
UpstreamApi::ChatCompletions => {
|
||||
assert_eq!(seen.target, "/v1/chat/completions");
|
||||
assert_eq!(seen.headers["authorization"], "Bearer fixture-secret");
|
||||
assert_eq!(converted["messages"][0]["content"], "fixture prompt");
|
||||
}
|
||||
UpstreamApi::AnthropicMessages => {
|
||||
assert_eq!(seen.target, "/v1/messages");
|
||||
assert_eq!(seen.headers["x-api-key"], "fixture-secret");
|
||||
assert_eq!(seen.headers["anthropic-version"], "2023-06-01");
|
||||
assert!(!seen.headers.contains_key("authorization"));
|
||||
assert_eq!(converted["max_tokens"], 8192);
|
||||
}
|
||||
UpstreamApi::Gemini => {
|
||||
assert!(seen.target.starts_with("/v1beta/models/fixture"));
|
||||
assert!(seen.target.ends_with(":generateContent"));
|
||||
assert_eq!(seen.headers["x-goog-api-key"], "fixture-secret");
|
||||
assert!(!seen.headers.contains_key("authorization"));
|
||||
assert_eq!(converted["contents"][0]["role"], "user");
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(!seen.headers.values().any(|v| v.contains(TOKEN)));
|
||||
let models = client()
|
||||
.get(&format!("http://127.0.0.1:{}/v1/models", proxy.port()))
|
||||
.set("Authorization", &format!("Bearer {TOKEN}"))
|
||||
.call()
|
||||
.unwrap()
|
||||
.into_string()
|
||||
.unwrap();
|
||||
let models: Value = serde_json::from_str(&models).unwrap();
|
||||
assert_eq!(models["object"], "list");
|
||||
assert_eq!(models["data"][0]["id"], MODEL);
|
||||
assert_eq!(upstream.observed().len(), 1);
|
||||
proxy.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_routes_reject_remote_state_and_compaction_without_upstream_request() {
|
||||
let upstream = Upstream::json(200, "{}");
|
||||
let mut supplier = route("converted", &upstream, "fixture-secret");
|
||||
supplier.protocol = UpstreamApi::Gemini;
|
||||
let proxy = start(vec![supplier]);
|
||||
let error = client()
|
||||
.post(&format!("http://127.0.0.1:{}/v1/responses", proxy.port()))
|
||||
.set("Authorization", &format!("Bearer {TOKEN}"))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(
|
||||
&json!({"model":MODEL,"input":"x","previous_response_id":"opaque-server-state"})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ureq::Error::Status(400, _)));
|
||||
let error = client()
|
||||
.post(&format!(
|
||||
"http://127.0.0.1:{}/v1/responses/compact",
|
||||
proxy.port()
|
||||
))
|
||||
.set("Authorization", &format!("Bearer {TOKEN}"))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&json!({"model":MODEL,"input":"x"}).to_string())
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ureq::Error::Status(400, _)));
|
||||
assert!(upstream.observed().is_empty());
|
||||
proxy.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_bridge_sse_error_can_fail_over_but_committed_output_is_not_replayed() {
|
||||
let early = Upstream::serve(|stream| {
|
||||
let data = "event: error\ndata: {\"error\":{\"message\":\"fixture outage\"}}\n\n";
|
||||
write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{data}",data.len()).unwrap();
|
||||
});
|
||||
let backup = Upstream::json(
|
||||
200,
|
||||
r#"{"id":"backup","choices":[{"message":{"content":"recovered"},"finish_reason":"stop"}]}"#,
|
||||
);
|
||||
let mut first = route("early", &early, "one");
|
||||
first.protocol = UpstreamApi::ChatCompletions;
|
||||
let mut second = route("backup", &backup, "two");
|
||||
second.protocol = UpstreamApi::ChatCompletions;
|
||||
let proxy = start(vec![first, second]);
|
||||
let result = request(&proxy, &stream_payload()).into_string().unwrap();
|
||||
assert!(result.contains("response.completed"));
|
||||
assert!(result.contains("recovered"));
|
||||
assert_eq!(backup.observed().len(), 1);
|
||||
proxy.shutdown();
|
||||
let (release, await_release) = mpsc::channel();
|
||||
let late = Upstream::serve(move |stream| {
|
||||
let first="data: {\"id\":\"partial\",\"choices\":[{\"delta\":{\"content\":\"committed\"}}]}\n\n";
|
||||
write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{:x}\r\n{first}\r\n",first.len()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
await_release.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
let error = "event: error\ndata: {\"error\":{\"message\":\"late outage\"}}\n\n";
|
||||
write!(stream, "{:x}\r\n{error}\r\n0\r\n\r\n", error.len()).unwrap();
|
||||
});
|
||||
let untouched = Upstream::json(200, "{}");
|
||||
let mut first = route("late", &late, "one");
|
||||
first.protocol = UpstreamApi::ChatCompletions;
|
||||
let proxy = start(vec![first, route("untouched", &untouched, "two")]);
|
||||
let result = request(&proxy, &stream_payload());
|
||||
release.send(()).unwrap();
|
||||
let result = result.into_string().unwrap();
|
||||
// The client can receive EOF before the worker publishes final statistics.
|
||||
eventually(|| proxy.snapshot().in_flight == 0);
|
||||
assert!(result.contains("response.failed"));
|
||||
assert!(!result.contains("response.completed"));
|
||||
assert!(untouched.observed().is_empty());
|
||||
assert!(proxy.snapshot().last_error.is_some());
|
||||
proxy.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_stream_endpoint_converts_sse_and_preserves_function_call() {
|
||||
let upstream = Upstream::serve(|stream| {
|
||||
let events = [
|
||||
json!({"responseId":"g","candidates":[{"content":{"parts":[{"text":"中文🙂"}]}}]}),
|
||||
json!({"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{"path":"x"}},"thoughtSignature":"fixture-signature"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2}}),
|
||||
];
|
||||
let body = events
|
||||
.iter()
|
||||
.map(|event| format!("data: {event}\r\n\r\n"))
|
||||
.collect::<String>();
|
||||
write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").unwrap();
|
||||
for chunk in body.as_bytes().chunks(1) {
|
||||
write!(stream, "1\r\n").unwrap();
|
||||
stream.write_all(chunk).unwrap();
|
||||
stream.write_all(b"\r\n").unwrap();
|
||||
}
|
||||
stream.write_all(b"0\r\n\r\n").unwrap();
|
||||
});
|
||||
let mut supplier = route("gemini", &upstream, "secret");
|
||||
supplier.protocol = UpstreamApi::Gemini;
|
||||
let proxy = start(vec![supplier]);
|
||||
let bytes=serde_json::to_vec(&json!({"model":MODEL,"input":"x","stream":true,"tools":[{"type":"function","name":"read"}]})).unwrap();
|
||||
let output = request(&proxy, &bytes).into_string().unwrap();
|
||||
assert!(output.contains("response.completed"));
|
||||
assert!(output.contains("中文🙂"));
|
||||
assert!(output.contains("function_call"));
|
||||
assert!(output.contains("encrypted_content"));
|
||||
let seen = &upstream.observed()[0];
|
||||
assert!(seen.target.contains(":streamGenerateContent?alt=sse"));
|
||||
proxy.shutdown();
|
||||
}
|
||||
|
||||
fn client() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.try_proxy_from_env(false)
|
||||
|
||||
@@ -27,6 +27,7 @@ impl Fixture {
|
||||
api_key: Some(format!("fixture-provider-secret-{index}")),
|
||||
toml_config: None,
|
||||
wire_api: "responses".into(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: false,
|
||||
model_mappings: vec![],
|
||||
};
|
||||
@@ -373,6 +374,407 @@ fn failed_manual_switch_reattaches_original_route() {
|
||||
assert_eq!(status.primary.unwrap().id, fixture.providers[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_provider_requires_live_takeover_and_shutdown_preserves_bridge() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].upstream_api = Some("gemini".into());
|
||||
fixture.providers[1] =
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
assert!(ensure_conversion_route(fixture.scope(), &fixture.providers[1]).is_err());
|
||||
fixture.enable();
|
||||
ensure_conversion_route(fixture.scope(), &fixture.providers[1]).unwrap();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[1].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let mut disabled = get_status(fixture.scope()).unwrap().settings;
|
||||
disabled.router_enabled = false;
|
||||
assert!(save_settings(fixture.scope(), disabled)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("协议转换"));
|
||||
shutdown_all().unwrap();
|
||||
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!(!get_status(fixture.scope()).unwrap().running);
|
||||
assert!(fixture.text().contains("127.0.0.1"));
|
||||
resume_after_failed_update().unwrap();
|
||||
assert!(get_status(fixture.scope()).unwrap().takeover_active);
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[0].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
stop(&fixture);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_preflight_is_read_only_for_native_and_converted_routes() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
assert!(!SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
ensure_shutdown_allowed().unwrap();
|
||||
assert!(!SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
assert_eq!(
|
||||
before.config,
|
||||
FileCheckpoint::capture(&fixture.dir).unwrap().config
|
||||
);
|
||||
fixture.providers[1].upstream_api = Some("gemini".into());
|
||||
fixture.providers[1] =
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[1].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let (routes, revision, instance) = {
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
(
|
||||
runtime.routes.clone(),
|
||||
runtime.proxy.revision(),
|
||||
runtime.instance_id,
|
||||
)
|
||||
};
|
||||
for flag in [false, true] {
|
||||
SHUTTING_DOWN.store(flag, Ordering::Release);
|
||||
ensure_shutdown_allowed().unwrap();
|
||||
assert_eq!(SHUTTING_DOWN.load(Ordering::Acquire), flag);
|
||||
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);
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
assert!(runtime.routes == routes);
|
||||
assert_eq!(runtime.proxy.revision(), revision);
|
||||
assert_eq!(runtime.instance_id, instance);
|
||||
assert!(runtime.journal.is_some());
|
||||
}
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn converted_fixture(api: &str) -> Fixture {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].upstream_api = Some(api.into());
|
||||
fixture.providers[1] =
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.auto_failover_enabled = false;
|
||||
save_settings(fixture.scope(), settings).unwrap();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[1].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
fixture
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_normal_exit_restarts_the_owned_bridge_without_direct_restore() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = converted_fixture("gemini");
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let settings = get_status(fixture.scope()).unwrap().settings;
|
||||
ensure_shutdown_allowed().unwrap();
|
||||
shutdown_all().unwrap();
|
||||
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!(!lock_manager().unwrap().contains_key(&fixture.dir));
|
||||
assert!(SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
let called = std::cell::Cell::new(false);
|
||||
assert!(with_provider_change(fixture.scope(), || {
|
||||
called.set(true);
|
||||
Ok(())
|
||||
})
|
||||
.is_err());
|
||||
assert!(!called.get());
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
initialize().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert_eq!(status.settings, settings);
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
assert_eq!(runtime.routes[0].protocol, UpstreamApi::Gemini);
|
||||
assert!(runtime.journal.as_ref().unwrap().requires_conversion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_handoff_freezes_mutations_without_stopping_native_or_converted_listener() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
for conversion in [false, true] {
|
||||
let fixture = if conversion {
|
||||
converted_fixture("anthropic_messages")
|
||||
} else {
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
fixture
|
||||
};
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let (revision, instance) = {
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
(runtime.proxy.revision(), runtime.instance_id)
|
||||
};
|
||||
begin_update_handoff().unwrap();
|
||||
assert!(SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
let called = std::cell::Cell::new(false);
|
||||
assert!(with_provider_change(fixture.scope(), || {
|
||||
called.set(true);
|
||||
fs::write(crate::config_path(&fixture.dir), "changed").unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.is_err());
|
||||
assert!(!called.get());
|
||||
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
|
||||
assert!(refresh_saved_routes().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);
|
||||
{
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
assert_eq!(runtime.proxy.revision(), revision);
|
||||
assert_eq!(runtime.instance_id, instance);
|
||||
assert!(runtime.journal.is_some());
|
||||
}
|
||||
resume_after_failed_update().unwrap();
|
||||
assert!(!SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
{
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
assert_eq!(runtime.proxy.revision(), revision);
|
||||
assert_eq!(runtime.instance_id, instance);
|
||||
}
|
||||
with_provider_change(fixture.scope(), || Ok(())).unwrap();
|
||||
assert!(get_status(fixture.scope()).unwrap().takeover_active);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_stopped_update_handoff_resumes_the_same_protocol_and_settings() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = converted_fixture("anthropic_messages");
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let settings = get_status(fixture.scope()).unwrap().settings;
|
||||
shutdown_all().unwrap();
|
||||
assert!(!lock_manager().unwrap().contains_key(&fixture.dir));
|
||||
assert_eq!(
|
||||
before.config,
|
||||
FileCheckpoint::capture(&fixture.dir).unwrap().config
|
||||
);
|
||||
resume_after_failed_update().unwrap();
|
||||
assert!(!SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert_eq!(status.settings, settings);
|
||||
assert_eq!(
|
||||
lock_manager().unwrap().get(&fixture.dir).unwrap().routes[0].protocol,
|
||||
UpstreamApi::AnthropicMessages
|
||||
);
|
||||
assert_eq!(
|
||||
before.auth,
|
||||
FileCheckpoint::capture(&fixture.dir).unwrap().auth
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_shutdown_keeps_external_selection_and_does_not_reattach_it() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = converted_fixture("gemini");
|
||||
let mut doc = parsed(&fixture);
|
||||
doc["model_provider"] = value("external");
|
||||
let mut table = Table::new();
|
||||
table["name"] = value("External fixture");
|
||||
table["base_url"] = value("https://external-fixture.example.test/v1");
|
||||
table["wire_api"] = value("responses");
|
||||
table["requires_openai_auth"] = value(false);
|
||||
replace_table(&mut doc, "external", table).unwrap();
|
||||
let external = doc.to_string();
|
||||
fs::write(crate::config_path(&fixture.dir), &external).unwrap();
|
||||
shutdown_all().unwrap();
|
||||
assert_eq!(fixture.text(), external);
|
||||
assert!(!load_record(&fixture.dir).unwrap().settings.takeover_enabled);
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
initialize().unwrap();
|
||||
assert_eq!(fixture.text(), external);
|
||||
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
|
||||
assert!(runtime.journal.is_none());
|
||||
runtime.proxy.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_shutdown_failure_keeps_files_listener_and_mutation_gate_open() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = converted_fixture("gemini");
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let record = load_record(&fixture.dir).unwrap();
|
||||
let mut missing = record.clone();
|
||||
missing.journals.clear();
|
||||
save_record(&fixture.dir, &missing).unwrap();
|
||||
assert!(ensure_shutdown_allowed().is_err());
|
||||
assert!(begin_update_handoff().is_err());
|
||||
assert!(!SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
assert!(shutdown_all().is_err());
|
||||
assert!(!SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
assert!(lock_manager().unwrap().contains_key(&fixture.dir));
|
||||
assert_eq!(
|
||||
before.config,
|
||||
FileCheckpoint::capture(&fixture.dir).unwrap().config
|
||||
);
|
||||
save_record(&fixture.dir, &record).unwrap();
|
||||
let mut stale_settings = record.clone();
|
||||
stale_settings.settings.takeover_enabled = false;
|
||||
save_record(&fixture.dir, &stale_settings).unwrap();
|
||||
let conn = crate::app_db::open().unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER reject_conversion_shutdown BEFORE UPDATE ON provider_failover BEGIN SELECT RAISE(ABORT,'fixture-conversion-shutdown-failure'); END;").unwrap();
|
||||
let result = shutdown_all();
|
||||
conn.execute_batch("DROP TRIGGER reject_conversion_shutdown")
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("fixture-conversion-shutdown-failure"));
|
||||
assert!(!SHUTTING_DOWN.load(Ordering::Acquire));
|
||||
assert!(lock_manager().unwrap().contains_key(&fixture.dir));
|
||||
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);
|
||||
save_record(&fixture.dir, &record).unwrap();
|
||||
with_provider_change(fixture.scope(), || Ok(())).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_reattach_failure_restores_complete_files_selection_and_runtime() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].upstream_api = Some("anthropic_messages".into());
|
||||
fixture.providers[1] =
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
fixture.enable();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[1].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let result = with_provider_change(fixture.scope(), || {
|
||||
fs::write(crate::config_path(&fixture.dir), "not valid = [toml").unwrap();
|
||||
fs::write(
|
||||
crate::auth_path(&fixture.dir),
|
||||
r#"{"OPENAI_API_KEY":"replacement-fixture"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.unwrap_err().to_string().contains("恢复原配置"));
|
||||
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);
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert_eq!(status.primary.unwrap().id, fixture.providers[1].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_route_restart_keeps_protocol_and_stale_recovery_never_restores_raw_url() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].upstream_api = Some("gemini".into());
|
||||
fixture.providers[1] =
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.auto_failover_enabled = false;
|
||||
save_settings(fixture.scope(), settings).unwrap();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[1].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let original = fixture.text();
|
||||
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
|
||||
runtime.proxy.shutdown();
|
||||
assert!(recover_stale_route(fixture.scope())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("协议转换"));
|
||||
assert_eq!(fixture.text(), original);
|
||||
initialize().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert!(fixture.text().contains("127.0.0.1"));
|
||||
assert_eq!(
|
||||
lock_manager().unwrap().get(&fixture.dir).unwrap().routes[0].protocol,
|
||||
UpstreamApi::Gemini
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failover_to_converted_provider_preserves_close_guard() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].upstream_api = Some("gemini".into());
|
||||
fixture.providers[1] =
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
fixture.enable();
|
||||
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();
|
||||
assert!(
|
||||
runtimes
|
||||
.get(&fixture.dir)
|
||||
.unwrap()
|
||||
.journal
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.requires_conversion
|
||||
);
|
||||
drop(runtimes);
|
||||
let before = fixture.text();
|
||||
let mut disabled = get_status(fixture.scope()).unwrap().settings;
|
||||
disabled.takeover_enabled = false;
|
||||
assert!(save_settings(fixture.scope(), disabled).is_err());
|
||||
assert_eq!(fixture.text(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keeps_external_edits_and_does_not_leave_local_headers() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::process::Command;
|
||||
|
||||
mod app_db;
|
||||
mod app_update;
|
||||
mod app_update_channel;
|
||||
mod backups;
|
||||
mod ccswitch;
|
||||
mod config_health;
|
||||
@@ -238,6 +239,12 @@ pub(crate) fn sanitize_id(input: &str) -> String {
|
||||
}
|
||||
|
||||
fn default_codex_dir() -> Result<PathBuf> {
|
||||
// Explicit app-only override for isolated native validation and portable setups.
|
||||
if let Ok(value) = std::env::var("CODEXX_CONFIG_DIR") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
if let Ok(value) = std::env::var("CODEX_HOME") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
@@ -938,6 +945,25 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_order(config_dir: Option<String>) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || providers::get_provider_order_inner(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_order(
|
||||
config_dir: Option<String>,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::save_provider_order_inner(config_dir, order)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
enum ActiveProviderSelectionUpdate {
|
||||
Set(String),
|
||||
ClearIfOfficial,
|
||||
@@ -1012,6 +1038,19 @@ async fn build_provider_toml_draft(
|
||||
.map_err(|e| CodexxError::Config(format!("生成供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_provider_headers(config_text: String) -> Result<Vec<providers::ProviderHeader>> {
|
||||
providers::read_provider_headers_inner(config_text)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_provider_headers(
|
||||
config_text: String,
|
||||
headers: Vec<providers::ProviderHeader>,
|
||||
) -> Result<String> {
|
||||
providers::update_provider_headers_inner(config_text, headers)
|
||||
}
|
||||
|
||||
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
let saved = save_provider_inner(provider)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
@@ -1096,6 +1135,11 @@ async fn activate_saved_provider(
|
||||
provider_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider = providers::list_saved_providers_inner()?
|
||||
.into_iter()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
.ok_or_else(|| CodexxError::Config("供应商不存在".to_string()))?;
|
||||
failover::ensure_conversion_route(config_dir.clone(), &provider)?;
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
|
||||
Ok(finish_provider_selection(
|
||||
@@ -1115,6 +1159,9 @@ async fn save_active_provider(
|
||||
apply_common_config: Option<bool>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider =
|
||||
providers::normalize_saved_provider_for_save(&providers::open_store()?, provider)?;
|
||||
failover::ensure_conversion_route(config_dir.clone(), &provider)?;
|
||||
let result = failover::with_provider_change(config_dir.clone(), || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = if apply_common_config.unwrap_or(false) {
|
||||
@@ -1148,6 +1195,24 @@ async fn delete_saved_provider(id: String, config_dir: Option<String>) -> Result
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_provider_model_catalog(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<providers::model_catalog::ProviderModelCatalogRepairResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let changed = providers::model_catalog::upgrade_owned_model_catalog(&codex_dir)?;
|
||||
Ok(providers::model_catalog::ProviderModelCatalogRepairResult {
|
||||
changed,
|
||||
message: changed.then(|| {
|
||||
"已修复供应商模型菜单的图片输入和思考强度,请重启 Codex 后使用".to_string()
|
||||
}),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("修复供应商模型菜单失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_codex_state(config_dir: Option<String>) -> Result<CodexState> {
|
||||
tauri::async_runtime::spawn_blocking(move || get_codex_state_inner(config_dir))
|
||||
@@ -1532,20 +1597,46 @@ async fn save_provider_toml_config(
|
||||
async fn test_provider_connection(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || test_provider_connection_inner(base_url, api_key))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("测试连接失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if upstream_api.is_none() && config_text.is_none() {
|
||||
test_provider_connection_inner(base_url, api_key)
|
||||
} else {
|
||||
providers::test_provider_connection_with_protocol_inner(
|
||||
base_url,
|
||||
api_key,
|
||||
upstream_api,
|
||||
config_text,
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("测试连接失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_provider_models(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || fetch_provider_models_inner(base_url, api_key))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("获取模型列表失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if upstream_api.is_none() && config_text.is_none() {
|
||||
fetch_provider_models_inner(base_url, api_key)
|
||||
} else {
|
||||
providers::fetch_provider_models_with_protocol_inner(
|
||||
base_url,
|
||||
api_key,
|
||||
upstream_api,
|
||||
config_text,
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("获取模型列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1768,6 +1859,7 @@ pub fn run() {
|
||||
})
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_update_channel::check_online_app_update,
|
||||
app_update::install_app_update,
|
||||
get_about_info,
|
||||
restart_codex_desktop,
|
||||
@@ -1802,8 +1894,12 @@ pub fn run() {
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
list_saved_providers,
|
||||
get_provider_order,
|
||||
save_provider_order,
|
||||
get_provider_config_base,
|
||||
build_provider_toml_draft,
|
||||
read_provider_headers,
|
||||
update_provider_headers,
|
||||
save_provider,
|
||||
duplicate_provider,
|
||||
update_codex_context_window,
|
||||
@@ -1815,6 +1911,7 @@ pub fn run() {
|
||||
activate_saved_provider,
|
||||
delete_saved_provider,
|
||||
get_codex_state,
|
||||
repair_provider_model_catalog,
|
||||
switch_official_provider,
|
||||
list_official_profiles,
|
||||
get_official_profile,
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
};
|
||||
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::failover::protocol::UpstreamApi;
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use crate::string_value;
|
||||
use crate::toml_utils::ensure_table;
|
||||
@@ -43,6 +44,7 @@ pub(crate) struct CcSwitchCodexRow {
|
||||
pub(crate) name: String,
|
||||
pub(crate) settings_config: String,
|
||||
pub(crate) category: Option<String>,
|
||||
pub(crate) meta: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn is_official_ccswitch_row(row: &CcSwitchCodexRow) -> bool {
|
||||
@@ -60,8 +62,13 @@ pub(crate) fn read_ccswitch_codex_rows(conn: &Connection) -> Result<Vec<CcSwitch
|
||||
} else {
|
||||
"NULL"
|
||||
};
|
||||
let meta_column = if provider_columns.contains("meta") {
|
||||
"meta"
|
||||
} else {
|
||||
"NULL"
|
||||
};
|
||||
let provider_query = format!(
|
||||
"SELECT id, name, settings_config, {category_column} FROM providers
|
||||
"SELECT id, name, settings_config, {category_column}, {meta_column} FROM providers
|
||||
WHERE app_type = 'codex' ORDER BY sort_index ASC, created_at ASC"
|
||||
);
|
||||
let mut stmt = conn
|
||||
@@ -74,6 +81,7 @@ pub(crate) fn read_ccswitch_codex_rows(conn: &Connection) -> Result<Vec<CcSwitch
|
||||
name: row.get::<_, String>(1)?,
|
||||
settings_config: row.get::<_, String>(2)?,
|
||||
category: row.get::<_, Option<String>>(3)?,
|
||||
meta: row.get::<_, Option<String>>(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -116,6 +124,94 @@ fn ccswitch_auth_api_key(settings: &Value) -> Option<String> {
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn explicit_ccswitch_api_format(
|
||||
row: &CcSwitchCodexRow,
|
||||
object: &Value,
|
||||
prefix: &str,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
for key in ["apiFormat", "api_format"] {
|
||||
let Some(value) = object.get(key).filter(|value| !value.is_null()) else {
|
||||
continue;
|
||||
};
|
||||
let field = format!("{prefix}.{key}");
|
||||
let text = value.as_str().ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 {field} 必须是协议名称文本",
|
||||
row.id
|
||||
))
|
||||
})?;
|
||||
if !text.trim().is_empty() {
|
||||
return Ok(Some((text.trim().to_string(), field)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn ccswitch_upstream_api(
|
||||
row: &CcSwitchCodexRow,
|
||||
settings: &Value,
|
||||
wire_api: &str,
|
||||
provider_id: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
let mut selected = None;
|
||||
if let Some(text) = row.meta.as_deref().filter(|text| !text.trim().is_empty()) {
|
||||
let meta: Value = serde_json::from_str(text).map_err(|_| {
|
||||
CodexxError::Config(format!("cc-switch 供应商 {} 的 meta 不是有效 JSON", row.id))
|
||||
})?;
|
||||
if !meta.is_null() && !meta.is_object() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 meta 必须是 JSON 对象",
|
||||
row.id
|
||||
)));
|
||||
}
|
||||
selected = explicit_ccswitch_api_format(row, &meta, "meta")?;
|
||||
}
|
||||
if selected.is_none() {
|
||||
if let Some(meta) = settings.get("meta").filter(|meta| !meta.is_null()) {
|
||||
if !meta.is_object() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 settings.meta 必须是 JSON 对象",
|
||||
row.id
|
||||
)));
|
||||
}
|
||||
selected = explicit_ccswitch_api_format(row, meta, "settings.meta")?;
|
||||
}
|
||||
}
|
||||
if selected.is_none() {
|
||||
selected = explicit_ccswitch_api_format(row, settings, "settings")?;
|
||||
}
|
||||
let explicit = selected.is_some();
|
||||
let (format, field) = selected.unwrap_or_else(|| {
|
||||
(
|
||||
wire_api.trim().to_string(),
|
||||
format!(
|
||||
"config.model_providers.{}.wire_api",
|
||||
provider_id.unwrap_or("<当前供应商>")
|
||||
),
|
||||
)
|
||||
});
|
||||
let protocol = UpstreamApi::from_provider(Some(&format), "responses").map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 {field} 上游协议不受支持;请选择 Responses、Chat Completions、Anthropic Messages 或 Gemini",
|
||||
row.id
|
||||
))
|
||||
})?;
|
||||
// An old native template says nothing about a locally selected bridge.
|
||||
// Leave None so a refresh can retain Codex-X's independent metadata.
|
||||
if !explicit && protocol == UpstreamApi::Responses {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(
|
||||
match protocol {
|
||||
UpstreamApi::Responses => "responses",
|
||||
UpstreamApi::ChatCompletions => "chat_completions",
|
||||
UpstreamApi::AnthropicMessages => "anthropic_messages",
|
||||
UpstreamApi::Gemini => "gemini",
|
||||
}
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn codex_section_from_table(
|
||||
id: &str,
|
||||
table: &Table,
|
||||
@@ -285,6 +381,12 @@ fn ccswitch_provider_template(
|
||||
}
|
||||
providers.insert(provider_id, Item::Table(table));
|
||||
}
|
||||
let table = providers
|
||||
.get_mut(provider_id)
|
||||
.and_then(Item::as_table_mut)?;
|
||||
if table.get("wire_api").and_then(Item::as_str) != Some("responses") {
|
||||
table["wire_api"] = value("responses");
|
||||
}
|
||||
strip_provider_bearer_tokens(&mut doc);
|
||||
Some(doc.to_string().trim_end().to_string())
|
||||
}
|
||||
@@ -292,9 +394,18 @@ fn ccswitch_provider_template(
|
||||
pub(crate) fn build_ccswitch_codex_provider(
|
||||
row: &CcSwitchCodexRow,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<SavedProvider> {
|
||||
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
|
||||
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
|
||||
) -> Result<Option<SavedProvider>> {
|
||||
let Ok(settings) = serde_json::from_str::<Value>(&row.settings_config) else {
|
||||
ccswitch_upstream_api(row, &Value::Null, "responses", None)?;
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(section) = select_ccswitch_section_for_row(row, &settings, global_sections) else {
|
||||
// A configured but unsupported protocol should not be hidden behind
|
||||
// the generic incomplete-provider warning.
|
||||
ccswitch_upstream_api(row, &settings, "responses", None)?;
|
||||
return Ok(None);
|
||||
};
|
||||
let upstream_api = ccswitch_upstream_api(row, &settings, §ion.wire_api, Some(§ion.id))?;
|
||||
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
|
||||
let provider_name = section
|
||||
.name
|
||||
@@ -304,19 +415,25 @@ pub(crate) fn build_ccswitch_codex_provider(
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| row.id.clone());
|
||||
let model = section.model.clone()?;
|
||||
let toml_config = ccswitch_provider_template(&settings, §ion, &provider_name, &model)?;
|
||||
Some(SavedProvider {
|
||||
let Some(model) = section.model.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(toml_config) = ccswitch_provider_template(&settings, §ion, &provider_name, &model)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(SavedProvider {
|
||||
id: custom_provider_id(&row.id),
|
||||
provider_name,
|
||||
base_url: section.base_url,
|
||||
model,
|
||||
api_key,
|
||||
toml_config: Some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
upstream_api,
|
||||
model_mappings: Vec::new(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Result<ImportResult> {
|
||||
@@ -388,7 +505,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match build_ccswitch_codex_provider(&row, &global_sections) {
|
||||
match build_ccswitch_codex_provider(&row, &global_sections)? {
|
||||
Some(provider) => {
|
||||
let provider = normalize_saved_provider(provider)?;
|
||||
let result =
|
||||
@@ -521,6 +638,249 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn protocol_row(format: Option<&str>) -> CcSwitchCodexRow {
|
||||
CcSwitchCodexRow {
|
||||
id: "protocol-fixture".into(),
|
||||
name: "Protocol fixture".into(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "fixture-api-key"},
|
||||
"config": "# keep imported comment\nmodel_provider='custom'\nmodel='fixture-model'\n[model_providers.custom]\nname='Protocol fixture'\nbase_url='https://neutral-gateway.example.test/custom-api-prefix'\nwire_api='responses'\nrequires_openai_auth=false\nhttp_headers={X-Title='Fixture title'}\n[mcp_servers.keep]\ncommand='fixture-server'\n",
|
||||
}).to_string(),
|
||||
category: None,
|
||||
meta: format.map(|format| json!({"apiFormat":format}).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_ccswitch_database(with_meta: bool) -> (tempfile::TempDir, Connection) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = Connection::open(dir.path().join("fixture-ccswitch.db")).unwrap();
|
||||
let optional = if with_meta {
|
||||
"category TEXT, meta TEXT,"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
conn.execute_batch(&format!(
|
||||
"CREATE TABLE providers (id TEXT, name TEXT, app_type TEXT, settings_config TEXT, {optional} sort_index INTEGER, created_at INTEGER);"
|
||||
)).unwrap();
|
||||
(dir, conn)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_database_reads_metadata_and_builds_all_four_protocols_without_endpoint_guessing() {
|
||||
let (_dir, conn) = fixture_ccswitch_database(true);
|
||||
for (index, (format, canonical)) in [
|
||||
("openai_responses", "responses"),
|
||||
("openai_chat", "chat_completions"),
|
||||
("anthropic", "anthropic_messages"),
|
||||
("gemini", "gemini"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut row = protocol_row(Some(format));
|
||||
row.id = format!("protocol-{index}");
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id,name,app_type,settings_config,meta,sort_index,created_at) VALUES (?1,?2,'codex',?3,?4,?5,1)",
|
||||
rusqlite::params![row.id,row.name,row.settings_config,row.meta,index],
|
||||
).unwrap();
|
||||
let read = read_ccswitch_codex_rows(&conn).unwrap().pop().unwrap();
|
||||
let provider = build_ccswitch_codex_provider(&read, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(provider.upstream_api.as_deref(), Some(canonical));
|
||||
assert_eq!(provider.wire_api, "responses");
|
||||
assert_eq!(
|
||||
provider.base_url,
|
||||
"https://neutral-gateway.example.test/custom-api-prefix"
|
||||
);
|
||||
let text = provider.toml_config.unwrap();
|
||||
assert!(text.contains("# keep imported comment"));
|
||||
let doc = text.parse::<DocumentMut>().unwrap();
|
||||
let table = doc["model_providers"]["custom"].as_table().unwrap();
|
||||
assert_eq!(table["wire_api"].as_str(), Some("responses"));
|
||||
assert_eq!(
|
||||
table["http_headers"]["X-Title"].as_str(),
|
||||
Some("Fixture title")
|
||||
);
|
||||
assert!(table.get("upstream_api").is_none());
|
||||
assert!(table.get("apiFormat").is_none());
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["keep"]["command"].as_str(),
|
||||
Some("fixture-server")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_database_without_metadata_columns_preserves_absence_and_uses_settings_formats() {
|
||||
let (_dir, conn) = fixture_ccswitch_database(false);
|
||||
let mut row = protocol_row(None);
|
||||
let mut settings: Value = serde_json::from_str(&row.settings_config).unwrap();
|
||||
settings["api_format"] = json!("anthropic_messages");
|
||||
row.settings_config = settings.to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id,name,app_type,settings_config,sort_index,created_at) VALUES (?1,?2,'codex',?3,0,1)",
|
||||
rusqlite::params![row.id,row.name,row.settings_config],
|
||||
).unwrap();
|
||||
let read = read_ccswitch_codex_rows(&conn).unwrap().pop().unwrap();
|
||||
assert!(read.category.is_none());
|
||||
assert!(read.meta.is_none());
|
||||
let provider = build_ccswitch_codex_provider(&read, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(provider.upstream_api.as_deref(), Some("anthropic_messages"));
|
||||
let native = build_ccswitch_codex_provider(&protocol_row(None), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(native.upstream_api.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_metadata_priority_is_database_meta_then_nested_settings_then_settings_format() {
|
||||
let mut row = protocol_row(Some("openai_chat"));
|
||||
let mut settings: Value = serde_json::from_str(&row.settings_config).unwrap();
|
||||
settings["meta"] = json!({"apiFormat":"anthropic"});
|
||||
settings["apiFormat"] = json!("gemini");
|
||||
settings["api_format"] = json!("responses");
|
||||
row.settings_config = settings.to_string();
|
||||
let db = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(db.upstream_api.as_deref(), Some("chat_completions"));
|
||||
row.meta = None;
|
||||
let nested = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(nested.upstream_api.as_deref(), Some("anthropic_messages"));
|
||||
settings.as_object_mut().unwrap().remove("meta");
|
||||
row.settings_config = settings.to_string();
|
||||
let camel = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(camel.upstream_api.as_deref(), Some("gemini"));
|
||||
settings.as_object_mut().unwrap().remove("apiFormat");
|
||||
row.settings_config = settings.to_string();
|
||||
let snake = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(snake.upstream_api.as_deref(), Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_metadata_overrides_legacy_wire_format_and_legacy_chat_migrates_native_toml() {
|
||||
let mut row = protocol_row(None);
|
||||
row.settings_config = row
|
||||
.settings_config
|
||||
.replace("wire_api='responses'", "wire_api='chat'");
|
||||
let legacy = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(legacy.upstream_api.as_deref(), Some("chat_completions"));
|
||||
assert_eq!(legacy.wire_api, "responses");
|
||||
assert_eq!(
|
||||
legacy.toml_config.unwrap().parse::<DocumentMut>().unwrap()["model_providers"]
|
||||
["custom"]["wire_api"]
|
||||
.as_str(),
|
||||
Some("responses")
|
||||
);
|
||||
row.meta = Some(json!({"apiFormat":"openai_responses"}).to_string());
|
||||
let explicit = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(explicit.upstream_api.as_deref(), Some("responses"));
|
||||
row.settings_config = row
|
||||
.settings_config
|
||||
.replace("wire_api='chat'", "wire_api='unknown-wire'");
|
||||
let overridden = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(overridden.upstream_api.as_deref(), Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_protocol_metadata_reports_its_provider_and_field_without_echoing_credentials() {
|
||||
for (meta, settings_format, key) in [
|
||||
(Some("future_protocol"), None, "meta.apiFormat"),
|
||||
(None, Some("future_protocol"), "settings.api_format"),
|
||||
] {
|
||||
let mut row = protocol_row(meta);
|
||||
if let Some(format) = settings_format {
|
||||
let mut settings: Value = serde_json::from_str(&row.settings_config).unwrap();
|
||||
settings["api_format"] = json!(format);
|
||||
row.settings_config = settings.to_string();
|
||||
}
|
||||
let error = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(&row.id));
|
||||
assert!(error.contains(key));
|
||||
assert!(error.contains("不受支持"));
|
||||
assert!(!error.contains("fixture-api-key"));
|
||||
}
|
||||
let mut row = protocol_row(None);
|
||||
row.settings_config = row
|
||||
.settings_config
|
||||
.replace("wire_api='responses'", "wire_api='future_wire'");
|
||||
let error = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("config.model_providers.custom.wire_api"));
|
||||
row.meta = Some(json!({"apiFormat":"future_protocol"}).to_string());
|
||||
row.settings_config = json!({"apiFormat":"responses","config":""}).to_string();
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metadata_and_non_text_protocol_fields_are_rejected_without_raw_json() {
|
||||
for meta in ["{fixture-private-meta", "[]", "{\"apiFormat\":42}"] {
|
||||
let mut row = protocol_row(None);
|
||||
row.meta = Some(meta.to_string());
|
||||
let error = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("meta"));
|
||||
assert!(!error.contains("fixture-private-meta"));
|
||||
assert!(!error.contains("fixture-api-key"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshing_old_native_input_keeps_the_local_protocol_but_an_explicit_choice_replaces_it() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE providers (id TEXT PRIMARY KEY,provider_name TEXT NOT NULL,base_url TEXT NOT NULL,model TEXT NOT NULL,api_key TEXT,toml_config TEXT,wire_api TEXT NOT NULL,requires_openai_auth INTEGER NOT NULL,model_mappings_json TEXT NOT NULL,upstream_api TEXT,source TEXT NOT NULL,source_id TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);").unwrap();
|
||||
let original = normalize_saved_provider(
|
||||
build_ccswitch_codex_provider(&protocol_row(Some("openai_chat")), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
upsert_ccswitch_provider_on_connection(&conn, original, "protocol-fixture").unwrap();
|
||||
let old_input = build_ccswitch_codex_provider(&protocol_row(None), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(old_input.upstream_api.is_none());
|
||||
let retained = upsert_ccswitch_provider_on_connection(
|
||||
&conn,
|
||||
normalize_saved_provider(old_input).unwrap(),
|
||||
"protocol-fixture",
|
||||
)
|
||||
.unwrap()
|
||||
.provider;
|
||||
assert_eq!(retained.upstream_api.as_deref(), Some("chat_completions"));
|
||||
let explicit =
|
||||
build_ccswitch_codex_provider(&protocol_row(Some("openai_responses")), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let replaced = upsert_ccswitch_provider_on_connection(
|
||||
&conn,
|
||||
normalize_saved_provider(explicit).unwrap(),
|
||||
"protocol-fixture",
|
||||
)
|
||||
.unwrap()
|
||||
.provider;
|
||||
assert_eq!(replaced.upstream_api.as_deref(), Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
|
||||
let settings_config = json!({
|
||||
@@ -540,6 +900,8 @@ wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-from-config"
|
||||
request_max_retries = 7
|
||||
http_headers = { "HTTP-Referer" = "https://header.example.test", "User-Agent" = "Fixture agent" }
|
||||
env_http_headers = { "X-Project" = "PROJECT_ID" }
|
||||
|
||||
[model_providers.other]
|
||||
name = "Other provider"
|
||||
@@ -576,10 +938,12 @@ command = "docs-server"
|
||||
name: " Sky2_free ".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let provider =
|
||||
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
|
||||
let provider = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.expect("valid cc-switch provider")
|
||||
.expect("build cc-switch provider");
|
||||
assert_eq!(provider.id, "magicai-123");
|
||||
assert_eq!(provider.provider_name, "Sky2api");
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
@@ -607,6 +971,18 @@ command = "docs-server"
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["http_headers"]["HTTP-Referer"].as_str(),
|
||||
Some("https://header.example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["http_headers"]["User-Agent"].as_str(),
|
||||
Some("Fixture agent")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["env_http_headers"]["X-Project"].as_str(),
|
||||
Some("PROJECT_ID")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["other"]["base_url"].as_str(),
|
||||
Some("https://other.example.com/v1")
|
||||
@@ -665,9 +1041,12 @@ requires_openai_auth = false
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_none());
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -697,8 +1076,11 @@ requires_openai_auth = false
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::failover::protocol::UpstreamApi;
|
||||
#[cfg(test)]
|
||||
use crate::remote::ensure_crypto_provider;
|
||||
use crate::remote::{remote_client, remote_request_error, RemoteSource};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
use toml_edit::{DocumentMut, Item};
|
||||
|
||||
const MODELS_SOURCE_KEY: &str = "获取模型列表";
|
||||
|
||||
@@ -46,18 +49,31 @@ struct ModelPayload {
|
||||
created: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiModelsPayload {
|
||||
models: Vec<GeminiModelPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiModelPayload {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
supported_generation_methods: Vec<String>,
|
||||
}
|
||||
|
||||
enum ProviderModelsAttempt {
|
||||
Success(ProviderModelsResult),
|
||||
HttpError { status: u16, duration_ms: u128 },
|
||||
}
|
||||
|
||||
fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
fn provider_base_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(CodexxError::Config("base_url 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let mut url = reqwest::Url::parse(trimmed)
|
||||
let url = reqwest::Url::parse(trimmed)
|
||||
.map_err(|_| CodexxError::Config("base_url 格式不正确".to_string()))?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err(CodexxError::Config(
|
||||
@@ -65,6 +81,11 @@ fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
));
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
let mut url = provider_base_url(base_url)?;
|
||||
let segments = url
|
||||
.path_segments()
|
||||
.ok_or_else(|| CodexxError::Config("base_url 格式不正确".to_string()))?
|
||||
@@ -93,6 +114,97 @@ fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn provider_models_url_with_protocol(
|
||||
base_url: &str,
|
||||
protocol: UpstreamApi,
|
||||
) -> Result<reqwest::Url> {
|
||||
if protocol != UpstreamApi::Gemini {
|
||||
let mut url = provider_base_url(base_url)?;
|
||||
let path = url.path().trim_end_matches('/');
|
||||
let suffix = match protocol {
|
||||
UpstreamApi::Responses => "/responses",
|
||||
UpstreamApi::ChatCompletions => "/chat/completions",
|
||||
UpstreamApi::AnthropicMessages => "/messages",
|
||||
UpstreamApi::Gemini => unreachable!(),
|
||||
};
|
||||
if let Some(prefix) = path.strip_suffix(suffix) {
|
||||
let prefix = prefix.to_string();
|
||||
url.set_path(&prefix);
|
||||
}
|
||||
return provider_models_url(url.as_str());
|
||||
}
|
||||
let mut url = provider_base_url(base_url)?;
|
||||
let path = url.path().trim_end_matches('/');
|
||||
// A pasted generateContent endpoint still points to the same API prefix.
|
||||
let prefix = path
|
||||
.split_once("/models/")
|
||||
.map_or(path, |(prefix, _)| prefix);
|
||||
let path = if prefix.ends_with("/models") {
|
||||
prefix.to_string()
|
||||
} else if prefix.ends_with("/v1") || prefix.ends_with("/v1beta") {
|
||||
format!("{prefix}/models")
|
||||
} else {
|
||||
format!("{prefix}/v1beta/models")
|
||||
};
|
||||
url.set_path(&path);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn provider_models_headers_with_env(
|
||||
config_text: Option<&str>,
|
||||
resolve_env: impl Fn(&str) -> Option<String>,
|
||||
) -> Result<HeaderMap> {
|
||||
let Some(config_text) = config_text.filter(|text| !text.trim().is_empty()) else {
|
||||
return Ok(HeaderMap::new());
|
||||
};
|
||||
let doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|_| CodexxError::Config("供应商 TOML 无效,请先修正配置".into()))?;
|
||||
let id = doc
|
||||
.get("model_provider")
|
||||
.and_then(Item::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| CodexxError::Config("供应商 TOML 缺少 model_provider".into()))?;
|
||||
let table = doc
|
||||
.get("model_providers")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|providers| providers.get(id))
|
||||
.and_then(Item::as_table)
|
||||
.ok_or_else(|| CodexxError::Config("供应商 TOML 缺少当前供应商配置表".into()))?;
|
||||
super::store::validate_provider_header_table(table)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
for header in super::store::read_provider_headers_inner(config_text.to_string())? {
|
||||
let text = match header.source {
|
||||
super::store::ProviderHeaderSource::Static => header.value,
|
||||
super::store::ProviderHeaderSource::Env => {
|
||||
resolve_env(&header.value).ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"供应商 Header ({}) 所需的环境变量不可用",
|
||||
header.name
|
||||
))
|
||||
})?
|
||||
}
|
||||
};
|
||||
let name = HeaderName::from_bytes(header.name.as_bytes())
|
||||
.map_err(|_| CodexxError::Config("供应商 Header 名称无效".into()))?;
|
||||
let mut value = HeaderValue::from_str(&text).map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"供应商 Header ({}) 值无效,不能包含换行或控制字符",
|
||||
header.name
|
||||
))
|
||||
})?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert(name, value);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn provider_models_headers(config_text: Option<&str>) -> Result<HeaderMap> {
|
||||
provider_models_headers_with_env(config_text, |name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
fn parse_created(value: Option<serde_json::Value>) -> Option<i64> {
|
||||
value.and_then(|value| {
|
||||
value
|
||||
@@ -158,10 +270,42 @@ fn natural_model_id_cmp(left: &str, right: &str) -> Ordering {
|
||||
fn parse_models(body: &str) -> Result<Vec<ProviderModel>> {
|
||||
let payload: ModelsPayload = serde_json::from_str(body)
|
||||
.map_err(|_| CodexxError::Config("模型列表返回格式不正确".to_string()))?;
|
||||
Ok(normalize_models(payload.data))
|
||||
}
|
||||
|
||||
fn parse_models_with_protocol(body: &str, protocol: UpstreamApi) -> Result<Vec<ProviderModel>> {
|
||||
if protocol != UpstreamApi::Gemini {
|
||||
return parse_models(body);
|
||||
}
|
||||
let payload: GeminiModelsPayload = serde_json::from_str(body)
|
||||
.map_err(|_| CodexxError::Config("模型列表返回格式不正确".to_string()))?;
|
||||
let models = payload
|
||||
.models
|
||||
.into_iter()
|
||||
.filter(|model| {
|
||||
model
|
||||
.supported_generation_methods
|
||||
.iter()
|
||||
.any(|method| method == "generateContent")
|
||||
})
|
||||
.map(|model| ModelPayload {
|
||||
id: model
|
||||
.name
|
||||
.trim()
|
||||
.strip_prefix("models/")
|
||||
.unwrap_or(model.name.trim())
|
||||
.to_string(),
|
||||
created: None,
|
||||
})
|
||||
.collect();
|
||||
Ok(normalize_models(models))
|
||||
}
|
||||
|
||||
fn normalize_models(payload: Vec<ModelPayload>) -> Vec<ProviderModel> {
|
||||
let mut models = Vec::<ProviderModel>::new();
|
||||
let mut indexes = HashMap::<String, usize>::new();
|
||||
|
||||
for model in payload.data {
|
||||
for model in payload {
|
||||
let id = model.id.trim();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
@@ -187,22 +331,70 @@ fn parse_models(body: &str) -> Result<Vec<ProviderModel>> {
|
||||
.then_with(|| natural_model_id_cmp(&right.id, &left.id))
|
||||
});
|
||||
|
||||
Ok(models)
|
||||
models
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn request_provider_models_with_client(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let url = provider_models_url(base_url)?;
|
||||
request_provider_models_with_protocol_client(
|
||||
client,
|
||||
base_url,
|
||||
api_key,
|
||||
UpstreamApi::Responses,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn request_provider_models_with_protocol_client(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
protocol: UpstreamApi,
|
||||
config_text: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let headers = provider_models_headers(config_text)?;
|
||||
request_provider_models_with_headers(client, base_url, api_key, protocol, headers)
|
||||
}
|
||||
|
||||
fn request_provider_models_with_headers(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
protocol: UpstreamApi,
|
||||
headers: HeaderMap,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let url = provider_models_url_with_protocol(base_url, protocol)?;
|
||||
let source = RemoteSource::new(MODELS_SOURCE_KEY, url.as_str(), Some("application/json"));
|
||||
let mut request = client
|
||||
.get(url.as_str())
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(api_key) = api_key.map(str::trim).filter(|key| !key.is_empty()) {
|
||||
request = request.bearer_auth(api_key);
|
||||
if protocol == UpstreamApi::AnthropicMessages {
|
||||
request = request.header("anthropic-version", "2023-06-01");
|
||||
}
|
||||
let custom_auth = ["authorization", "x-api-key", "x-goog-api-key"]
|
||||
.iter()
|
||||
.any(|name| headers.contains_key(*name));
|
||||
if let Some(api_key) = api_key
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty() && !custom_auth)
|
||||
{
|
||||
let (name, text) = match protocol {
|
||||
UpstreamApi::Responses | UpstreamApi::ChatCompletions => {
|
||||
("authorization", format!("Bearer {api_key}"))
|
||||
}
|
||||
UpstreamApi::AnthropicMessages => ("x-api-key", api_key.to_string()),
|
||||
UpstreamApi::Gemini => ("x-goog-api-key", api_key.to_string()),
|
||||
};
|
||||
let mut value = HeaderValue::from_str(&text)
|
||||
.map_err(|_| CodexxError::Config("供应商 API Key 格式不正确".into()))?;
|
||||
value.set_sensitive(true);
|
||||
request = request.header(name, value);
|
||||
}
|
||||
request = request.headers(headers);
|
||||
|
||||
let started = Instant::now();
|
||||
let response = request
|
||||
@@ -221,15 +413,21 @@ fn request_provider_models_with_client(
|
||||
.text()
|
||||
.map_err(|_| CodexxError::Config("模型列表读取失败".to_string()))?;
|
||||
Ok(ProviderModelsAttempt::Success(ProviderModelsResult {
|
||||
models: parse_models(&body)?,
|
||||
models: parse_models_with_protocol(&body, protocol)?,
|
||||
status,
|
||||
duration_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
fn request_provider_models(base_url: &str, api_key: Option<&str>) -> Result<ProviderModelsAttempt> {
|
||||
fn request_provider_models_with_protocol(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
upstream_api: Option<&str>,
|
||||
config_text: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let protocol = UpstreamApi::from_provider(upstream_api, "responses")?;
|
||||
let client = remote_client()?;
|
||||
request_provider_models_with_client(&client, base_url, api_key)
|
||||
request_provider_models_with_protocol_client(&client, base_url, api_key, protocol, config_text)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_status_result(status: u16, duration_ms: u128) -> ProviderConnectionResult {
|
||||
@@ -251,7 +449,21 @@ pub(crate) fn test_provider_connection_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
match request_provider_models(&base_url, api_key.as_deref())? {
|
||||
test_provider_connection_with_protocol_inner(base_url, api_key, None, None)
|
||||
}
|
||||
|
||||
pub(crate) fn test_provider_connection_with_protocol_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
match request_provider_models_with_protocol(
|
||||
&base_url,
|
||||
api_key.as_deref(),
|
||||
upstream_api.as_deref(),
|
||||
config_text.as_deref(),
|
||||
)? {
|
||||
ProviderModelsAttempt::Success(result) => {
|
||||
Ok(provider_status_result(result.status, result.duration_ms))
|
||||
}
|
||||
@@ -266,7 +478,21 @@ pub(crate) fn fetch_provider_models_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
match request_provider_models(&base_url, api_key.as_deref())? {
|
||||
fetch_provider_models_with_protocol_inner(base_url, api_key, None, None)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_provider_models_with_protocol_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
match request_provider_models_with_protocol(
|
||||
&base_url,
|
||||
api_key.as_deref(),
|
||||
upstream_api.as_deref(),
|
||||
config_text.as_deref(),
|
||||
)? {
|
||||
ProviderModelsAttempt::Success(result) => Ok(result),
|
||||
ProviderModelsAttempt::HttpError { status, .. } => {
|
||||
Err(CodexxError::Config(if matches!(status, 401 | 403) {
|
||||
@@ -434,4 +660,204 @@ mod tests {
|
||||
assert!(!message.contains(&base_url));
|
||||
server.join().expect("join mock server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_model_urls_preserve_api_prefix_and_do_not_repeat_versions() {
|
||||
for (protocol, base, expected) in [
|
||||
(UpstreamApi::Responses, "https://example.test/v1/responses", "https://example.test/v1/models"),
|
||||
(UpstreamApi::ChatCompletions, "https://example.test/v1/chat/completions", "https://example.test/v1/models"),
|
||||
(UpstreamApi::AnthropicMessages, "https://example.test", "https://example.test/v1/models"),
|
||||
(UpstreamApi::AnthropicMessages, "https://example.test/anthropic/v1/messages", "https://example.test/anthropic/v1/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test", "https://example.test/v1beta/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/v1beta", "https://example.test/v1beta/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/v1beta/", "https://example.test/v1beta/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/v1", "https://example.test/v1/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/api/google/v1/models", "https://example.test/api/google/v1/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/api/google/v1beta/models/gemini-2.5-pro:generateContent?route=fixture#ignored", "https://example.test/api/google/v1beta/models?route=fixture"),
|
||||
(UpstreamApi::Gemini, "https://example.test/api/google", "https://example.test/api/google/v1beta/models"),
|
||||
] {
|
||||
assert_eq!(provider_models_url_with_protocol(base, protocol).unwrap().as_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_and_anthropic_models_use_their_protocol_authentication() {
|
||||
for (protocol, auth) in [
|
||||
(
|
||||
UpstreamApi::ChatCompletions,
|
||||
"authorization: bearer fixture-protocol-key",
|
||||
),
|
||||
(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
"x-api-key: fixture-protocol-key",
|
||||
),
|
||||
] {
|
||||
let (url, server) = serve_once(200, r#"{"data":[{"id":"fixture-model"}]}"#);
|
||||
let attempt = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-protocol-key"),
|
||||
protocol,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let ProviderModelsAttempt::Success(result) = attempt else {
|
||||
panic!("expected model list")
|
||||
};
|
||||
assert_eq!(result.models[0].id, "fixture-model");
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /v1/models http/1.1"));
|
||||
assert!(request.contains(auth));
|
||||
if protocol == UpstreamApi::AnthropicMessages {
|
||||
assert!(request.contains("anthropic-version: 2023-06-01"));
|
||||
assert!(!request.contains("authorization:"));
|
||||
} else {
|
||||
assert!(!request.contains("x-api-key:"));
|
||||
}
|
||||
assert!(!request.contains("x-goog-api-key:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_models_use_google_header_and_include_only_generate_content_models() {
|
||||
let body = r#"{"models":[{"name":"models/gemini-2.5-pro","supportedGenerationMethods":["generateContent","countTokens"]},{"name":"models/text-embedding-004","supportedGenerationMethods":["embedContent"]},{"name":" models/gemini-2.5-flash ","supportedGenerationMethods":["generateContent"]},{"name":"models/gemini-2.5-pro","supportedGenerationMethods":["generateContent"]},{"name":"models/count-only","supportedGenerationMethods":["countTokens"]}]}"#;
|
||||
let (url, server) = serve_once(200, body);
|
||||
let attempt = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&format!("{url}/v1beta"),
|
||||
Some("fixture-google-key"),
|
||||
UpstreamApi::Gemini,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let ProviderModelsAttempt::Success(result) = attempt else {
|
||||
panic!("expected Gemini model list")
|
||||
};
|
||||
assert_eq!(
|
||||
result
|
||||
.models
|
||||
.iter()
|
||||
.map(|model| model.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["gemini-2.5-pro", "gemini-2.5-flash"]
|
||||
);
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /v1beta/models http/1.1"));
|
||||
assert!(request.contains("x-goog-api-key: fixture-google-key"));
|
||||
assert!(!request.contains("authorization:"));
|
||||
assert!(!request.contains("x-api-key:"));
|
||||
assert!(!request
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.contains("fixture-google-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_and_environment_headers_are_applied_to_model_discovery() {
|
||||
let config = "model_provider='custom'\n[model_providers.custom]\nhttp_headers={User-Agent='Fixture agent',HTTP-Referer='https://fixture.example.test'}\nenv_http_headers={X-Project='FIXTURE_PROJECT'}\n";
|
||||
let headers = provider_models_headers_with_env(Some(config), |name| {
|
||||
(name == "FIXTURE_PROJECT").then(|| "fixture-project-value".to_string())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!format!("{headers:?}").contains("fixture-project-value"));
|
||||
let (url, server) = serve_once(200, r#"{"data":[]}"#);
|
||||
request_provider_models_with_headers(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-api-key"),
|
||||
UpstreamApi::Responses,
|
||||
headers,
|
||||
)
|
||||
.unwrap();
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("user-agent: fixture agent"));
|
||||
assert!(request.contains("http-referer: https://fixture.example.test"));
|
||||
assert!(request.contains("x-project: fixture-project-value"));
|
||||
assert!(request.contains("authorization: bearer fixture-api-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_auth_and_anthropic_version_headers_override_request_defaults() {
|
||||
let config = "model_provider='custom'\n[model_providers.custom]\nhttp_headers={Authorization='Bearer fixture-custom-auth',anthropic-version='2099-01-01'}\n";
|
||||
let (url, server) = serve_once(200, r#"{"data":[]}"#);
|
||||
request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-unused-key"),
|
||||
UpstreamApi::AnthropicMessages,
|
||||
Some(config),
|
||||
)
|
||||
.unwrap();
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("authorization: bearer fixture-custom-auth"));
|
||||
assert!(request.contains("anthropic-version: 2099-01-01"));
|
||||
assert!(!request.contains("anthropic-version: 2023-06-01"));
|
||||
assert!(!request.contains("x-api-key:"));
|
||||
assert!(!request.contains("fixture-unused-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_validation_rejects_duplicates_and_invalid_resolved_values_without_secret_echo() {
|
||||
let duplicate = "model_provider='custom'\n[model_providers.custom]\nhttp_headers={X-Project='fixture-static-secret'}\nenv_http_headers={x-project='FIXTURE_PROJECT'}\n";
|
||||
let error = provider_models_headers_with_env(Some(duplicate), |_| {
|
||||
panic!("validation must happen before resolving environment values")
|
||||
})
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(!error.contains("fixture-static-secret"));
|
||||
assert!(error.contains("重复"));
|
||||
let env = "model_provider='custom'\n[model_providers.custom]\nenv_http_headers={X-Project='FIXTURE_PROJECT'}\n";
|
||||
for resolved in [
|
||||
None,
|
||||
Some("fixture-env-secret\r\nInjected: value".to_string()),
|
||||
] {
|
||||
let error = provider_models_headers_with_env(Some(env), |_| resolved.clone())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("X-Project"));
|
||||
assert!(!error.contains("fixture-env-secret"));
|
||||
assert!(!error.contains("Injected"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_payload_errors_and_authentication_statuses_do_not_echo_private_data() {
|
||||
let (url, server) = serve_once(200, r#"{"models":"fixture-private-response"}"#);
|
||||
let error = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-google-secret"),
|
||||
UpstreamApi::Gemini,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(!error.contains("fixture-private-response"));
|
||||
assert!(!error.contains("fixture-google-secret"));
|
||||
server.join().unwrap();
|
||||
let (url, server) = serve_once(401, r#"{"error":"fixture-auth-private-response"}"#);
|
||||
let attempt = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-anthropic-secret"),
|
||||
UpstreamApi::AnthropicMessages,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
} = attempt
|
||||
else {
|
||||
panic!("expected authentication status")
|
||||
};
|
||||
let result = provider_status_result(status, duration_ms);
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.status, Some(401));
|
||||
assert!(!result.message.contains("fixture-auth-private-response"));
|
||||
assert!(!result.message.contains("fixture-anthropic-secret"));
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +583,7 @@ pub(crate) fn detected_live_custom_provider(codex_dir: &Path) -> Result<Option<S
|
||||
toml_config: (!toml_config.is_empty()).then_some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
upstream_api: None,
|
||||
model_mappings: Vec::new(),
|
||||
}))
|
||||
}
|
||||
@@ -1203,6 +1204,7 @@ fn merge_provider_toml_into_live_with_policy(
|
||||
"config.toml 缺少 [model_providers.{source_provider_id}]"
|
||||
))
|
||||
})?;
|
||||
super::store::validate_provider_header_table(&source_provider)?;
|
||||
let source_name = source_provider
|
||||
.get("name")
|
||||
.and_then(|item| item.as_str())
|
||||
@@ -1768,6 +1770,52 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[test]
|
||||
fn header_values_survive_direct_toml_activation_and_invalid_values_are_rejected() {
|
||||
let config = "model_provider='custom'\nmodel='gpt-test'\n[model_providers.custom]\nname='Header fixture'\nbase_url='https://headers.example.test/v1'\nwire_api='responses'\nrequires_openai_auth=false\nhttp_headers={User-Agent='Fixture agent'}\nenv_http_headers={X-Project='PROJECT_ID'}\n";
|
||||
let (doc, _) =
|
||||
merge_provider_toml_into_live(Path::new("fixture.toml"), "", config, None).unwrap();
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["http_headers"]["User-Agent"].as_str(),
|
||||
Some("Fixture agent")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["env_http_headers"]["X-Project"].as_str(),
|
||||
Some("PROJECT_ID")
|
||||
);
|
||||
let invalid = config.replace(
|
||||
"User-Agent='Fixture agent'",
|
||||
"User-Agent=\"secret\\r\\ninjected\"",
|
||||
);
|
||||
let error = merge_provider_toml_into_live(Path::new("fixture.toml"), "", &invalid, None)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Header"));
|
||||
assert!(!error.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_api_key_removes_stale_authorization_headers_from_both_sources() {
|
||||
let mut doc = "[custom]\nhttp_headers={Authorization='Bearer stale',X-Trace='keep'}\nenv_http_headers={aUtHoRiZaTiOn='STALE_AUTH',X-Project='PROJECT_ID'}\n".parse::<DocumentMut>().unwrap();
|
||||
let table = doc["custom"].as_table_mut().unwrap();
|
||||
configure_live_provider_auth(table, Some("fixture-key"), false);
|
||||
assert!(table["http_headers"]
|
||||
.as_table_like()
|
||||
.unwrap()
|
||||
.get("Authorization")
|
||||
.is_none());
|
||||
assert!(table["env_http_headers"]
|
||||
.as_table_like()
|
||||
.unwrap()
|
||||
.get("aUtHoRiZaTiOn")
|
||||
.is_none());
|
||||
assert_eq!(table["http_headers"]["X-Trace"].as_str(), Some("keep"));
|
||||
assert_eq!(
|
||||
table["env_http_headers"]["X-Project"].as_str(),
|
||||
Some("PROJECT_ID")
|
||||
);
|
||||
}
|
||||
|
||||
fn active_provider_fixture(
|
||||
tag: u64,
|
||||
id: &str,
|
||||
@@ -1795,6 +1843,7 @@ requires_openai_auth = false
|
||||
)),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
upstream_api: None,
|
||||
model_mappings: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -2280,6 +2329,7 @@ command = "docs-server"
|
||||
toml_config: None,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
upstream_api: None,
|
||||
model_mappings: Vec::new(),
|
||||
},
|
||||
Some(codex_dir.display().to_string()),
|
||||
@@ -2848,7 +2898,7 @@ command = "docs-server"
|
||||
r#"env_key = "STALE_ENV_KEY"
|
||||
env_key_instructions = "Stale environment instructions"
|
||||
auth = { command = "fixture-never-executed" }
|
||||
http_headers = { Authorization = "Bearer stale", "X-Trace" = "keep-static" }
|
||||
http_headers = { "X-Trace" = "keep-static" }
|
||||
env_http_headers = { aUtHoRiZaTiOn = "STALE_AUTH", "X-Project" = "PROJECT_ENV" }
|
||||
"#,
|
||||
);
|
||||
@@ -2907,6 +2957,20 @@ env_http_headers = { aUtHoRiZaTiOn = "STALE_AUTH", "X-Project" = "PROJECT_ENV" }
|
||||
assert_eq!(models[0]["display_name"], "DeepSeek V3");
|
||||
assert_eq!(models[1]["slug"], "deepseek-reasoner");
|
||||
assert_eq!(models[1]["context_window"], 256000);
|
||||
for model in models {
|
||||
assert_eq!(
|
||||
model["input_modalities"],
|
||||
serde_json::json!(["text", "image"])
|
||||
);
|
||||
assert_eq!(
|
||||
model["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap()["effort"],
|
||||
"ultra"
|
||||
);
|
||||
}
|
||||
assert!(!fs::read_to_string(&pointer)
|
||||
.unwrap()
|
||||
.contains("fixture-current-key"));
|
||||
|
||||
@@ -24,8 +24,9 @@ pub(crate) use ccswitch::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use connection::provider_status_result;
|
||||
pub(crate) use connection::{
|
||||
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
|
||||
ProviderModelsResult,
|
||||
fetch_provider_models_inner, fetch_provider_models_with_protocol_inner,
|
||||
test_provider_connection_inner, test_provider_connection_with_protocol_inner,
|
||||
ProviderConnectionResult, ProviderModelsResult,
|
||||
};
|
||||
pub(crate) use live::{
|
||||
activate_saved_provider_inner, build_provider_toml_draft_inner,
|
||||
@@ -59,14 +60,15 @@ pub(crate) use store::{
|
||||
pub(crate) use store::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
|
||||
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, matching_saved_provider_ids_for_live_on_connection,
|
||||
normalize_saved_provider, normalize_saved_provider_for_save, provider_template_from_document,
|
||||
reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_detected_provider_with_rollback_inner, save_provider_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
|
||||
ProviderUpsertKind, SavedProvider,
|
||||
get_provider_order_inner, is_placeholder_provider, list_saved_providers_inner,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
matching_saved_provider_ids_for_live_on_connection, normalize_saved_provider,
|
||||
normalize_saved_provider_for_save, provider_template_from_document,
|
||||
read_provider_headers_inner, reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_detected_provider_with_rollback_inner, save_provider_inner, save_provider_order_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens, update_provider_headers_inner,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderHeader,
|
||||
ProviderStoreRollback, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_private_json};
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, atomic_write_if_unchanged, ensure_file_snapshot_unchanged,
|
||||
read_file_snapshot, text_from_snapshot,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -37,6 +41,13 @@ pub(crate) struct ProviderModelMapping {
|
||||
pub(crate) context_window: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModelCatalogRepairResult {
|
||||
pub(crate) changed: bool,
|
||||
pub(crate) message: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str, label: &str) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
@@ -111,8 +122,9 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
// Schema: openai/codex rust-v0.153.4, protocol/src/openai_models.rs,
|
||||
// ModelInfo + ModelsResponse. Keep legacy required fields for older Codex.
|
||||
// Unlike cloning a GPT cache entry, this does not import proprietary model
|
||||
// instructions, hosted tools, service tiers or vision. Expose the same effort
|
||||
// menu for mapped models; the upstream API determines each effort's effect.
|
||||
// instructions, hosted tools or service tiers. Allow image attachments instead
|
||||
// of making the client reject every third-party model before contacting its
|
||||
// provider. The provider determines the supported modalities and efforts.
|
||||
json!({
|
||||
"slug": mapping.model,
|
||||
"display_name": mapping.display_name,
|
||||
@@ -142,7 +154,7 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
"max_context_window": context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": ["text"],
|
||||
"input_modalities": ["text", "image"],
|
||||
"supports_search_tool": false,
|
||||
"use_responses_lite": false,
|
||||
"prefer_websockets": false
|
||||
@@ -344,6 +356,15 @@ pub(crate) fn prepare_model_catalog(
|
||||
"source": "codex-x", "format": 1,
|
||||
"provider_hash": format!("{:x}", Sha256::digest(provider_id.as_bytes()))
|
||||
});
|
||||
let path = persist_owned_catalog(codex_dir, &catalog)?;
|
||||
// Change the caller's document only after a complete catalog is available.
|
||||
// The caller owns the live config transaction; no auth/config files are
|
||||
// read or written here, and models_cache.json is intentionally untouched.
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_owned_catalog(codex_dir: &Path, catalog: &Value) -> Result<PathBuf> {
|
||||
let filename = catalog_filename(&catalog)?;
|
||||
let directory = prepare_owned_directory(codex_dir)?;
|
||||
let path = directory.join(filename);
|
||||
@@ -355,22 +376,101 @@ pub(crate) fn prepare_model_catalog(
|
||||
));
|
||||
}
|
||||
let existing = fs::read(&path).map_err(|error| io_err(&path, error))?;
|
||||
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(&catalog) {
|
||||
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(catalog) {
|
||||
return Err(CodexxError::Config(
|
||||
"已生成的模型目录文件被修改,未覆盖原文件".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
write_private_json(&path, &catalog)?
|
||||
write_private_json(&path, catalog)?
|
||||
}
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
}
|
||||
// Change the caller's document only after a complete catalog is available.
|
||||
// The caller owns the live config transaction; no auth/config files are
|
||||
// read or written here, and models_cache.json is intentionally untouched.
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Upgrade a verified Codex-X catalog already selected by an older app. Keep
|
||||
/// the old immutable file available for backups and failed provider switches.
|
||||
/// User catalogs, edited generated files and official configurations are skipped.
|
||||
pub(crate) fn upgrade_owned_model_catalog(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = crate::config_path(codex_dir);
|
||||
let original = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, original.as_deref())?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut doc = crate::file_io::parse_toml_document(&cfg, &text)?;
|
||||
if super::document_is_official(&doc) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(pointer) = doc.get(CATALOG_FIELD).and_then(|item| item.as_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !is_owned_pointer(codex_dir, pointer) {
|
||||
return Ok(false);
|
||||
}
|
||||
let pointer = Path::new(pointer);
|
||||
let source = if pointer.is_absolute() {
|
||||
pointer.to_path_buf()
|
||||
} else {
|
||||
codex_dir.join(pointer)
|
||||
};
|
||||
let Some(filename) = source.file_name().and_then(|name| name.to_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Ownership is stronger than the path-only recognition used for removing
|
||||
// stale pointers: only a digest-verified generated catalog may be upgraded.
|
||||
if !has_verified_ownership(&source, filename) {
|
||||
return Ok(false);
|
||||
}
|
||||
let bytes = fs::read(&source).map_err(|error| io_err(&source, error))?;
|
||||
let mut catalog: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| CodexxError::Config("供应商模型目录不是有效的 JSON".into()))?;
|
||||
// Recheck the read snapshot rather than trusting the first verification,
|
||||
// since a user or another process can edit the source between reads.
|
||||
if catalog_filename(&catalog)? != filename {
|
||||
return Ok(false);
|
||||
}
|
||||
let before = catalog.clone();
|
||||
let capabilities = model_entry(
|
||||
&ProviderModelMapping {
|
||||
model: String::new(),
|
||||
display_name: String::new(),
|
||||
context_window: None,
|
||||
},
|
||||
0,
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
);
|
||||
for model in catalog["models"]
|
||||
.as_array_mut()
|
||||
.expect("verified catalogs contain a models array")
|
||||
{
|
||||
if !model.is_object() {
|
||||
return Ok(false);
|
||||
}
|
||||
for key in [
|
||||
"input_modalities",
|
||||
"default_reasoning_level",
|
||||
"supported_reasoning_levels",
|
||||
"supports_reasoning_summaries",
|
||||
"supports_reasoning_summary_parameter",
|
||||
"default_reasoning_summary",
|
||||
] {
|
||||
model[key] = capabilities[key].clone();
|
||||
}
|
||||
}
|
||||
if catalog == before {
|
||||
return Ok(false);
|
||||
}
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
ensure_file_snapshot_unchanged(&cfg, original.as_deref())?;
|
||||
ensure_file_snapshot_unchanged(&source, Some(&bytes))?;
|
||||
let path = persist_owned_catalog(codex_dir, &catalog)?;
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
Ok(())
|
||||
crate::backups::create_backup(codex_dir, "upgrade-provider-model-capabilities")?;
|
||||
atomic_write_if_unchanged(&cfg, original.as_deref(), doc.to_string().as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -418,6 +518,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"vendor/custom-model",
|
||||
"gpt-5.5",
|
||||
"gpt-6-astra",
|
||||
] {
|
||||
let entry = model_entry(&mapping(id, id, None), 0, 128000);
|
||||
let levels: Vec<_> = entry["supported_reasoning_levels"]
|
||||
@@ -537,7 +638,7 @@ mod tests {
|
||||
assert_eq!(model["display_name"], "DeepSeek 常用");
|
||||
assert_eq!(model["context_window"], 131072);
|
||||
assert_eq!(model["apply_patch_tool_type"], Value::Null);
|
||||
assert_eq!(model["input_modalities"], json!(["text"]));
|
||||
assert_eq!(model["input_modalities"], json!(["text", "image"]));
|
||||
assert!(!catalog.to_string().contains("gpt-5.5"));
|
||||
assert!(!catalog.to_string().contains("PRIVATE"));
|
||||
assert!(!catalog.to_string().contains("namespace-secret"));
|
||||
@@ -605,6 +706,148 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn legacy_catalog(fixture: &Fixture, doc: &mut DocumentMut) -> (PathBuf, Vec<u8>) {
|
||||
prepare_model_catalog(
|
||||
&fixture.0,
|
||||
"provider",
|
||||
&[
|
||||
mapping("deepseek-flash", "DeepSeek Flash", Some(131072)),
|
||||
mapping("gpt-6-astra", "GPT-6 Astra", Some(1_000_000)),
|
||||
],
|
||||
"deepseek-flash",
|
||||
doc,
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy = catalog(doc);
|
||||
for model in legacy["models"].as_array_mut().unwrap() {
|
||||
model["input_modalities"] = json!(["text"]);
|
||||
model["supported_reasoning_levels"] = json!([
|
||||
{"effort": "low", "description": "Low effort"},
|
||||
{"effort": "max", "description": "Maximum effort"}
|
||||
]);
|
||||
model["supports_reasoning_summaries"] = json!(false);
|
||||
}
|
||||
let filename = catalog_filename(&legacy).unwrap();
|
||||
let path = owned_catalog_directory(&fixture.0).join(filename);
|
||||
let bytes = serde_json::to_vec(&legacy).unwrap();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
(path, bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrades_active_legacy_capabilities_without_touching_credentials_or_old_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc: DocumentMut = r#"# Keep this comment.
|
||||
model_provider = "custom"
|
||||
model = "deepseek-flash"
|
||||
model_reasoning_effort = "ultra"
|
||||
approval_policy = "never"
|
||||
[model_providers.custom]
|
||||
name = "DeepSeek"
|
||||
base_url = "https://fixture.invalid/v1"
|
||||
wire_api = "responses"
|
||||
"#
|
||||
.parse()
|
||||
.unwrap();
|
||||
let (old_path, old_bytes) = legacy_catalog(&fixture, &mut doc);
|
||||
fs::write(fixture.0.join("config.toml"), doc.to_string()).unwrap();
|
||||
fs::write(fixture.0.join("auth.json"), "untouched-fixture-credential").unwrap();
|
||||
fs::write(fixture.0.join("models_cache.json"), "untouched-model-cache").unwrap();
|
||||
|
||||
assert!(upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
let updated: DocumentMut = fs::read_to_string(fixture.0.join("config.toml"))
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_ne!(path(&updated), old_path);
|
||||
assert_eq!(fs::read(old_path).unwrap(), old_bytes);
|
||||
assert_eq!(updated["model"].as_str(), Some("deepseek-flash"));
|
||||
assert_eq!(updated["model_reasoning_effort"].as_str(), Some("ultra"));
|
||||
assert_eq!(updated["approval_policy"].as_str(), Some("never"));
|
||||
assert!(updated.to_string().contains("# Keep this comment."));
|
||||
assert_eq!(
|
||||
updated["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://fixture.invalid/v1")
|
||||
);
|
||||
let upgraded = catalog(&updated);
|
||||
assert_eq!(upgraded["models"][0]["slug"], "deepseek-flash");
|
||||
assert_eq!(upgraded["models"][1]["slug"], "gpt-6-astra");
|
||||
assert_eq!(upgraded["models"][0]["context_window"], 131072);
|
||||
assert_eq!(upgraded["models"][1]["context_window"], 1_000_000);
|
||||
for model in upgraded["models"].as_array().unwrap() {
|
||||
assert_eq!(model["input_modalities"], json!(["text", "image"]));
|
||||
assert_eq!(model["supports_reasoning_summaries"], true);
|
||||
assert_eq!(
|
||||
model["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap()["effort"],
|
||||
"ultra"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("auth.json")).unwrap(),
|
||||
"untouched-fixture-credential"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("models_cache.json")).unwrap(),
|
||||
"untouched-model-cache"
|
||||
);
|
||||
let config_after = fs::read(fixture.0.join("config.toml")).unwrap();
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("config.toml")).unwrap(),
|
||||
config_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_upgrade_skips_official_and_unverified_catalogs() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc: DocumentMut = "model_provider = 'custom'\nmodel = 'deepseek-flash'"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let (old_path, old_bytes) = legacy_catalog(&fixture, &mut doc);
|
||||
for provider in ["openai", "custom"] {
|
||||
doc["model_provider"] = value(provider);
|
||||
let config = doc.to_string();
|
||||
fs::write(fixture.0.join("config.toml"), &config).unwrap();
|
||||
if provider == "custom" {
|
||||
let mut modified: Value = serde_json::from_slice(&old_bytes).unwrap();
|
||||
modified["models"][0]["display_name"] = json!("User's own menu");
|
||||
fs::write(&old_path, serde_json::to_vec(&modified).unwrap()).unwrap();
|
||||
}
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
doc[CATALOG_FIELD] = value("my-models.json");
|
||||
let config = doc.to_string();
|
||||
fs::write(fixture.0.join("config.toml"), &config).unwrap();
|
||||
fs::write(fixture.0.join("my-models.json"), &old_bytes).unwrap();
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
|
||||
config
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("my-models.json")).unwrap(),
|
||||
old_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_upgrade_does_not_create_a_codex_home() {
|
||||
let fixture = Fixture::new();
|
||||
let missing = fixture.0.join("missing-codex-home");
|
||||
assert!(!upgrade_owned_model_catalog(&missing).unwrap());
|
||||
assert!(!missing.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_removes_only_our_pointer_without_deleting_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
use super::types::SessionPreview;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct DirectoryIdentity {
|
||||
key: String,
|
||||
birth_verified: bool,
|
||||
}
|
||||
|
||||
fn filesystem_identity(
|
||||
platform: &[u8],
|
||||
parts: &[&[u8]],
|
||||
birth: Option<SystemTime>,
|
||||
) -> DirectoryIdentity {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x/session-directory-identity/v1\0");
|
||||
digest.update(platform);
|
||||
digest.update(b"\0");
|
||||
for part in parts {
|
||||
digest.update(part);
|
||||
}
|
||||
if let Some(birth) = birth {
|
||||
digest.update(b"\0birth\0");
|
||||
let (sign, age) = match birth.duration_since(UNIX_EPOCH) {
|
||||
Ok(age) => (b'+', age),
|
||||
Err(before_epoch) => (b'-', before_epoch.duration()),
|
||||
};
|
||||
digest.update([sign]);
|
||||
digest.update(age.as_secs().to_le_bytes());
|
||||
digest.update(age.subsec_nanos().to_le_bytes());
|
||||
} else {
|
||||
digest.update(b"\0no-birth\0");
|
||||
}
|
||||
DirectoryIdentity {
|
||||
key: format!("filesystem:{:x}", digest.finalize()),
|
||||
birth_verified: birth.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn unix_directory_identity(
|
||||
device: u64,
|
||||
inode: u64,
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// Some virtual or unsupported filesystems expose no usable inode number.
|
||||
if inode == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"unix",
|
||||
&[&device.to_le_bytes(), &inode.to_le_bytes()],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_directory_identity(
|
||||
volume: u64,
|
||||
identifier: &[u8; 16],
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// The complete 128-bit ID is needed for ReFS; the old 64-bit file index
|
||||
// cannot safely distinguish all directories on that filesystem.
|
||||
if identifier.iter().all(|byte| *byte == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"windows",
|
||||
&[&volume.to_le_bytes(), identifier],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let handle = File::open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
unix_directory_identity(metadata.dev(), metadata.ino(), metadata.created().ok())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "directory has no stable inode"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FileIdInfo, GetFileInformationByHandleEx, FILE_FLAG_BACKUP_SEMANTICS, FILE_ID_INFO,
|
||||
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
// READ_ATTRIBUTES plus BACKUP_SEMANTICS opens directories without requiring
|
||||
// permission to list them. No content is read and no filesystem state is changed.
|
||||
let handle = OpenOptions::new()
|
||||
.read(true)
|
||||
.access_mode(FILE_READ_ATTRIBUTES)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
|
||||
.open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
let mut info = FILE_ID_INFO::default();
|
||||
// The owned read-only handle stays alive while Windows fills this exact
|
||||
// FILE_ID_INFO buffer. Unsupported filesystems return an error and fall back.
|
||||
let succeeded = unsafe {
|
||||
GetFileInformationByHandleEx(
|
||||
handle.as_raw_handle(),
|
||||
FileIdInfo,
|
||||
(&mut info as *mut FILE_ID_INFO).cast(),
|
||||
std::mem::size_of::<FILE_ID_INFO>() as u32,
|
||||
)
|
||||
};
|
||||
if succeeded == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
windows_directory_identity(
|
||||
info.VolumeSerialNumber,
|
||||
&info.FileId.Identifier,
|
||||
metadata.created().ok(),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory has no stable file ID",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn native_directory_identity(_path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory identity is unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
fn unresolved_directory_identity(path: &str) -> DirectoryIdentity {
|
||||
DirectoryIdentity {
|
||||
key: format!("path:{path}"),
|
||||
birth_verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory_lookup(path: &str) -> DirectoryIdentity {
|
||||
let filesystem_path = Path::new(path);
|
||||
// Historical relative cwd values cannot be resolved against this app's
|
||||
// process cwd. Check the path kind before opening, including FIFO/device
|
||||
// values that must never be treated as a workspace directory.
|
||||
if !filesystem_path.is_absolute()
|
||||
|| !std::fs::metadata(filesystem_path).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
return unresolved_directory_identity(path);
|
||||
}
|
||||
native_directory_identity(filesystem_path)
|
||||
.unwrap_or_else(|_| unresolved_directory_identity(path))
|
||||
}
|
||||
|
||||
fn directory_identity(path: &str) -> String {
|
||||
directory_lookup(path).key
|
||||
}
|
||||
|
||||
fn pin_scope_key(path: &str, identity: &DirectoryIdentity) -> String {
|
||||
if identity.birth_verified {
|
||||
identity.key.clone()
|
||||
} else {
|
||||
format!("path:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the paths represented by this status only. The raw spelling remains
|
||||
/// the map key. Persistent home scope uses native identity only when birth time
|
||||
/// was verified; otherwise it conservatively follows the original home path.
|
||||
pub(super) fn session_directory_identities(
|
||||
codex_dir: &Path,
|
||||
sessions: &[SessionPreview],
|
||||
) -> (HashMap<String, String>, String) {
|
||||
let mut identities = HashMap::new();
|
||||
let home = codex_dir.display().to_string();
|
||||
let home_identity = directory_lookup(&home);
|
||||
let pin_scope = pin_scope_key(&home, &home_identity);
|
||||
identities.insert(home.clone(), home_identity.key);
|
||||
for path in sessions.iter().filter_map(|session| session.cwd.as_deref()) {
|
||||
identities
|
||||
.entry(path.to_string())
|
||||
.or_insert_with(|| directory_identity(path));
|
||||
}
|
||||
(identities, pin_scope)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestDirectory {
|
||||
path: PathBuf,
|
||||
_directory: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix("codex-x-directory-identity-");
|
||||
let directory = match std::env::var_os("CODEXX_TEST_CASE_DIRECTORY_ROOT") {
|
||||
Some(root) => builder.tempdir_in(root),
|
||||
None => builder.tempdir(),
|
||||
}
|
||||
.expect("create temporary directory identity fixture");
|
||||
Self {
|
||||
path: directory.path().to_path_buf(),
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(id: &str, cwd: Option<String>) -> SessionPreview {
|
||||
SessionPreview {
|
||||
id: id.to_string(),
|
||||
title: id.to_string(),
|
||||
cwd,
|
||||
model_provider: None,
|
||||
model: None,
|
||||
rollout_path: None,
|
||||
updated_at_ms: None,
|
||||
archived: false,
|
||||
has_user_event: false,
|
||||
is_subagent: false,
|
||||
needs_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_and_lexical_alias_have_the_same_stable_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
let direct = directory_identity(&folder.display().to_string());
|
||||
let alias = directory_identity(&folder.join(".").display().to_string());
|
||||
assert!(direct.starts_with("filesystem:"));
|
||||
assert_eq!(direct, alias);
|
||||
assert_eq!(direct, directory_identity(&folder.display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symbolic_directory_alias_resolves_to_the_same_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
let alias = fixture.path.join("WorkspaceAlias");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
std::os::unix::fs::symlink(&folder, &alias).unwrap();
|
||||
assert_eq!(
|
||||
directory_identity(&folder.display().to_string()),
|
||||
directory_identity(&alias.display().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_only_names_follow_the_actual_fixture_filesystem() {
|
||||
let fixture = TestDirectory::new();
|
||||
let upper = fixture.path.join("CaseDirectory");
|
||||
let lower = fixture.path.join("casedirectory");
|
||||
fs::create_dir(&upper).unwrap();
|
||||
let separate_directories = match fs::create_dir(&lower) {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false,
|
||||
Err(error) => panic!("create case-only directory fixture: {error}"),
|
||||
};
|
||||
let upper_key = directory_identity(&upper.display().to_string());
|
||||
let lower_key = directory_identity(&lower.display().to_string());
|
||||
assert!(upper_key.starts_with("filesystem:"));
|
||||
assert!(lower_key.starts_with("filesystem:"));
|
||||
if separate_directories {
|
||||
assert_ne!(
|
||||
upper_key, lower_key,
|
||||
"case-sensitive directories must remain distinct"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
upper_key, lower_key,
|
||||
"case aliases of the same directory must match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_never_borrow_identity_from_the_application_working_directory() {
|
||||
for raw in [".", "./", "src", "foo", "C:Project", " relative\\Name "] {
|
||||
assert_eq!(directory_identity(raw), format!("path:{raw}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_nondirectory_paths_keep_the_complete_original_spelling() {
|
||||
let fixture = TestDirectory::new();
|
||||
for suffix in ["Missing", "missing", "Missing\\Name ", " missing "] {
|
||||
let raw = fixture.path.join(suffix).display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
}
|
||||
let file = fixture.path.join("a-file");
|
||||
fs::write(&file, b"read-only identity lookup must not alter contents").unwrap();
|
||||
let raw = file.display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
assert_eq!(
|
||||
fs::read(&file).unwrap(),
|
||||
b"read-only identity lookup must not alter contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_directory_falls_back_without_normalizing_its_name() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("UnreadableDirectory");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0)).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let readable_by_process = File::open(&directory).is_ok();
|
||||
let identity = directory_identity(&raw);
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
if !readable_by_process {
|
||||
assert_eq!(identity, format!("path:{raw}"));
|
||||
} else {
|
||||
// A privileged test runner can bypass the permission restriction.
|
||||
assert!(identity.starts_with("filesystem:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_native_identifiers_are_rejected_and_full_windows_file_ids_are_used() {
|
||||
assert!(unix_directory_identity(1, 0, None).is_none());
|
||||
assert!(windows_directory_identity(1, &[0; 16], None).is_none());
|
||||
let mut first = [0; 16];
|
||||
first[0] = 7;
|
||||
let mut second = first;
|
||||
second[15] = 1;
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &first, None),
|
||||
windows_directory_identity(1, &second, None),
|
||||
"ReFS IDs that differ only beyond the old 64-bit index must not merge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_generation_includes_epoch_sign_full_seconds_and_nanoseconds() {
|
||||
use std::time::Duration;
|
||||
// Windows SystemTime uses 100 ns intervals; use exactly representable
|
||||
// fractions while still testing that subsecond birth precision matters.
|
||||
let created = UNIX_EPOCH + Duration::new(1234, 100);
|
||||
let original = unix_directory_identity(1, 9, Some(created)).unwrap();
|
||||
assert!(original.birth_verified);
|
||||
assert_eq!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(created)).unwrap()
|
||||
);
|
||||
for new_generation in [
|
||||
UNIX_EPOCH + Duration::new(1234, 200),
|
||||
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 100),
|
||||
UNIX_EPOCH - Duration::new(1234, 100),
|
||||
] {
|
||||
assert_ne!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(new_generation)).unwrap(),
|
||||
"reused inode numbers from another directory generation must get different keys"
|
||||
);
|
||||
}
|
||||
let file_id = [7; 16];
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &file_id, Some(created)),
|
||||
windows_directory_identity(1, &file_id, Some(created + Duration::from_nanos(100)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_pin_scope_requires_verified_birth_and_preserves_raw_fallback_paths() {
|
||||
let transient = unix_directory_identity(1, 9, None).unwrap();
|
||||
assert!(transient.key.starts_with("filesystem:"));
|
||||
assert!(!transient.birth_verified);
|
||||
for path in ["/Home/Codex", "/Home/codex", "/Home/Codex\\ "] {
|
||||
assert_eq!(pin_scope_key(path, &transient), format!("path:{path}"));
|
||||
}
|
||||
let verified = unix_directory_identity(1, 9, Some(UNIX_EPOCH)).unwrap();
|
||||
assert_eq!(pin_scope_key("/Home/Codex", &verified), verified.key);
|
||||
assert_eq!(pin_scope_key("/Home/codex", &verified), verified.key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_directory_contents_does_not_change_birth_identity_or_pin_scope() {
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("Workspace");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let before = directory_lookup(&raw);
|
||||
fs::write(directory.join("first.txt"), b"first").unwrap();
|
||||
fs::write(directory.join("first.txt"), b"updated").unwrap();
|
||||
fs::write(directory.join("second.txt"), b"second").unwrap();
|
||||
fs::remove_file(directory.join("first.txt")).unwrap();
|
||||
let after = directory_lookup(&raw);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"mtime/ctime changes must not replace the directory birth identity"
|
||||
);
|
||||
assert_eq!(pin_scope_key(&raw, &before), pin_scope_key(&raw, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_identities_include_only_its_home_and_loaded_workspaces_and_isolate_homes() {
|
||||
let fixture = TestDirectory::new();
|
||||
let first_home = fixture.path.join("CodexHomeA");
|
||||
let second_home = fixture.path.join("CodexHomeB");
|
||||
let workspace = fixture.path.join("Project");
|
||||
let unused = fixture.path.join("Unused");
|
||||
for directory in [&first_home, &second_home, &workspace, &unused] {
|
||||
fs::create_dir(directory).unwrap();
|
||||
}
|
||||
let config = first_home.join("config.toml");
|
||||
fs::write(&config, b"model_provider = \"openai\"\n").unwrap();
|
||||
let cwd = workspace.display().to_string();
|
||||
let sessions = vec![
|
||||
preview("first", Some(cwd.clone())),
|
||||
preview("second", Some(cwd.clone())),
|
||||
preview("unknown", None),
|
||||
];
|
||||
let (first, first_scope) = session_directory_identities(&first_home, &sessions);
|
||||
let (second, second_scope) = session_directory_identities(&second_home, &sessions);
|
||||
assert_eq!(first.len(), 2);
|
||||
assert!(first.contains_key(&first_home.display().to_string()));
|
||||
assert!(first.contains_key(&cwd));
|
||||
assert!(!first.contains_key(&unused.display().to_string()));
|
||||
assert_ne!(
|
||||
first[&first_home.display().to_string()],
|
||||
second[&second_home.display().to_string()]
|
||||
);
|
||||
assert_ne!(first_scope, second_scope);
|
||||
assert_eq!(first[&cwd], second[&cwd]);
|
||||
assert_eq!(fs::read(&config).unwrap(), b"model_provider = \"openai\"\n");
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod directory_identity;
|
||||
mod export;
|
||||
mod global_state;
|
||||
mod rollout_stream;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod transaction;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,39 @@
|
||||
use super::global_state::normalize_workspace_path;
|
||||
use super::rollout_stream::{read_first_meta, scan_rollout_stream};
|
||||
use super::types::{RolloutScan, SessionFileChange, SessionPreview, SqliteScan};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{
|
||||
atomic_write, io_err, json_err, parse_toml_document, read_to_string_if_exists,
|
||||
};
|
||||
use crate::file_io::{io_err, parse_toml_document, read_to_string_if_exists};
|
||||
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 sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
/// Digest without retaining log contents, regardless of file or record size.
|
||||
pub(super) fn hash_rollout_file(path: &Path) -> std::io::Result<[u8; 32]> {
|
||||
hash_reader(fs::File::open(path)?)
|
||||
}
|
||||
|
||||
fn hash_reader<R: Read>(mut reader: R) -> std::io::Result<[u8; 32]> {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let count = reader.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
Ok(hasher.finalize().into())
|
||||
}
|
||||
|
||||
pub(super) fn current_model_provider(codex_dir: &Path, explicit: Option<String>) -> Result<String> {
|
||||
if let Some(provider) = explicit
|
||||
.map(|s| s.trim().to_string())
|
||||
@@ -183,16 +199,6 @@ fn collect_rollout_paths(root: &Path, out: &mut Vec<PathBuf>, failures: &mut Vec
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn split_line_ending(segment: &str) -> (&str, &str) {
|
||||
if let Some(line) = segment.strip_suffix("\r\n") {
|
||||
(line, "\r\n")
|
||||
} else if let Some(line) = segment.strip_suffix('\n') {
|
||||
(line, "\n")
|
||||
} else {
|
||||
(segment, "")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_locked_io_error(error: &std::io::Error) -> bool {
|
||||
matches!(error.kind(), std::io::ErrorKind::PermissionDenied)
|
||||
|| matches!(error.raw_os_error(), Some(32 | 33))
|
||||
@@ -301,7 +307,17 @@ fn scan_rollouts_with_thread_filter(
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.and_then(|canonical| referenced.get(&canonical));
|
||||
let identity = match read_rollout_identity(&path) {
|
||||
let mut rollout_file = match fs::File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) => {
|
||||
scan.scan_failures.push(format!(
|
||||
"无法读取会话文件来源信息: {} ({error})",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let identity = match read_rollout_identity_from_reader(&mut rollout_file, &path) {
|
||||
Ok(identity) => identity,
|
||||
Err(failure) => {
|
||||
scan.scan_failures.push(failure);
|
||||
@@ -329,156 +345,56 @@ fn scan_rollouts_with_thread_filter(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
let reason = if is_locked_io_error(&error) {
|
||||
"会话文件被占用或无权限读取"
|
||||
} else {
|
||||
"无法读取会话文件"
|
||||
};
|
||||
scan.scan_failures
|
||||
.push(format!("{reason}: {} ({error})", path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut next_text = String::with_capacity(text.len());
|
||||
let mut rewrite_needed = false;
|
||||
let mut file_session_meta_count = 0usize;
|
||||
let mut file_mismatched_session_meta = 0usize;
|
||||
let mut invalid_json_lines = 0usize;
|
||||
let mut invalid_session_meta = 0usize;
|
||||
let mut thread_id = None;
|
||||
let mut cwd = None;
|
||||
let mut is_subagent = false;
|
||||
|
||||
for segment in text.split_inclusive('\n') {
|
||||
let (line, line_ending) = split_line_ending(segment);
|
||||
let mut next_line = line.to_string();
|
||||
if !line.trim().is_empty() {
|
||||
if let Ok(mut record) = serde_json::from_str::<Value>(line) {
|
||||
if record.get("type").and_then(Value::as_str) == Some("session_meta") {
|
||||
if let Some(payload) =
|
||||
record.get_mut("payload").and_then(Value::as_object_mut)
|
||||
{
|
||||
file_session_meta_count += 1;
|
||||
if file_session_meta_count == 1 {
|
||||
thread_id = payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
cwd = payload
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_workspace_path);
|
||||
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)
|
||||
{
|
||||
payload.insert(
|
||||
"model_provider".to_string(),
|
||||
Value::String(target_provider.to_string()),
|
||||
);
|
||||
next_line = serde_json::to_string(&record)
|
||||
.map_err(|error| json_err(&path, error))?;
|
||||
rewrite_needed = true;
|
||||
file_mismatched_session_meta += 1;
|
||||
}
|
||||
} else {
|
||||
invalid_session_meta += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalid_json_lines += 1;
|
||||
}
|
||||
}
|
||||
next_text.push_str(&next_line);
|
||||
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()
|
||||
.is_some_and(|id| excluded_thread_ids.is_some_and(|excluded| excluded.contains(id)))
|
||||
if excluded_thread_ids.is_some_and(|excluded| excluded.contains(identity_id))
|
||||
|| allowed_thread_ids.is_some_and(|allowed| !allowed.contains(identity_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if invalid_json_lines > 0 {
|
||||
scan.scan_failures.push(format!(
|
||||
"会话文件包含 {invalid_json_lines} 行无法解析的 JSON: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if invalid_session_meta > 0 {
|
||||
scan.scan_failures.push(format!(
|
||||
"会话文件包含 {invalid_session_meta} 条无法读取的 session_meta: {}",
|
||||
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() {
|
||||
let streamed = match scan_rollout_stream(&mut rollout_file, &path, target_provider) {
|
||||
Ok(streamed) => streamed,
|
||||
Err(error) => {
|
||||
scan.scan_failures.push(format!(
|
||||
"活动 SQLite 引用的会话文件缺少 session_meta: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(thread_id) = thread_id else {
|
||||
scan.scan_failures.push(format!(
|
||||
"会话文件的 session_meta 缺少 id: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if let Some(expected_thread_ids) = expected_thread_ids {
|
||||
if expected_thread_ids.len() != 1 || !expected_thread_ids.contains(&thread_id) {
|
||||
scan.scan_failures.push(format!(
|
||||
"活动 SQLite 引用的会话文件与线程 ID 不一致: {}",
|
||||
"会话文件未完成检查或改写: {} ({error})",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if allowed_thread_ids.is_some_and(|allowed| !allowed.contains(&thread_id)) {
|
||||
};
|
||||
let thread_id = streamed.first_meta.id.trim().to_string();
|
||||
// The same open handle is used for identity and complete scanning.
|
||||
// Still reject a changed first record instead of pairing mismatched snapshots.
|
||||
if thread_id != identity_id
|
||||
|| streamed.first_meta.is_internal != identity.payload.is_internal()
|
||||
{
|
||||
scan.scan_failures.push(format!(
|
||||
"会话来源信息在扫描期间发生变化: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
scan.session_meta_count += file_session_meta_count;
|
||||
scan.session_meta_count += streamed.session_meta_count;
|
||||
scan.provider_candidate_paths.insert(path.clone());
|
||||
scan.verified_rollout_hashes
|
||||
.insert(path.clone(), streamed.original_hash);
|
||||
scan.thread_ids.insert(thread_id.clone());
|
||||
if let Some(cwd) = cwd {
|
||||
if let Some(cwd) = streamed
|
||||
.first_meta
|
||||
.cwd
|
||||
.as_deref()
|
||||
.and_then(normalize_workspace_path)
|
||||
{
|
||||
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
|
||||
}
|
||||
if rewrite_needed {
|
||||
if let Some(snapshot) = streamed.staged {
|
||||
scan.mismatched_rollouts += 1;
|
||||
scan.mismatched_session_meta += file_mismatched_session_meta;
|
||||
scan.mismatched_session_meta += streamed.mismatch_count;
|
||||
scan.mismatched_thread_ids.insert(thread_id);
|
||||
scan.changes.push(SessionFileChange {
|
||||
original_mtime: fs::metadata(&path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.ok(),
|
||||
path,
|
||||
original_text: text,
|
||||
next_text,
|
||||
original_text: String::new(),
|
||||
next_text: String::new(),
|
||||
original_mtime: streamed.original_mtime,
|
||||
streamed: Some(snapshot),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -509,48 +425,135 @@ fn rollout_file_is_open(_path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn ensure_rollout_snapshot_unchanged(path: &Path, expected: &[u8; 32]) -> Result<()> {
|
||||
if hash_rollout_file(path).map_err(|error| io_err(path, error))? == *expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(format!(
|
||||
"会话文件已变化,已取消写入: {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_write_rollout<R: Read>(
|
||||
path: &Path,
|
||||
mut reader: R,
|
||||
expected_current: &[u8; 32],
|
||||
expected_output: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| CodexxError::Config("会话文件缺少父目录".into()))?;
|
||||
let permissions = fs::metadata(path)
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.permissions();
|
||||
let mut temporary = tempfile::Builder::new()
|
||||
.prefix(".codex-x-rollout-")
|
||||
.tempfile_in(parent)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let count = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
temporary
|
||||
.write_all(&buffer[..count])
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
let output_hash: [u8; 32] = hasher.finalize().into();
|
||||
if output_hash != *expected_output {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话改写临时快照已变化,已取消写入: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
temporary
|
||||
.as_file()
|
||||
.set_permissions(permissions)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
temporary
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
ensure_rollout_snapshot_unchanged(path, expected_current)?;
|
||||
temporary
|
||||
.persist(path)
|
||||
.map_err(|error| io_err(path, error.error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_session_change(change: &SessionFileChange, restore: bool) -> Result<()> {
|
||||
let (current, output) = if restore {
|
||||
(change.next_hash(), change.original_hash())
|
||||
} else {
|
||||
(change.original_hash(), change.next_hash())
|
||||
};
|
||||
if let Some(snapshot) = &change.streamed {
|
||||
let source = if restore {
|
||||
&snapshot.original_path
|
||||
} else {
|
||||
&snapshot.next_path
|
||||
};
|
||||
let reader = fs::File::open(source).map_err(|error| io_err(source, error))?;
|
||||
atomic_write_rollout(&change.path, reader, ¤t, &output)
|
||||
} else {
|
||||
let text = if restore {
|
||||
&change.original_text
|
||||
} else {
|
||||
&change.next_text
|
||||
};
|
||||
atomic_write_rollout(&change.path, text.as_bytes(), ¤t, &output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_session_changes(
|
||||
changes: &[SessionFileChange],
|
||||
) -> Result<(Vec<SessionFileChange>, Vec<PathBuf>)> {
|
||||
apply_session_changes_with_hook(changes, |_| Ok(()))
|
||||
}
|
||||
|
||||
pub(super) fn apply_session_changes_with_hook<F>(
|
||||
changes: &[SessionFileChange],
|
||||
mut hook: F,
|
||||
) -> Result<(Vec<SessionFileChange>, Vec<PathBuf>)>
|
||||
where
|
||||
F: FnMut(usize) -> Result<()>,
|
||||
{
|
||||
let mut applied = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for change in changes {
|
||||
if rollout_file_is_open(&change.path) {
|
||||
skipped.push(change.path.clone());
|
||||
continue;
|
||||
}
|
||||
match fs::read_to_string(&change.path) {
|
||||
Ok(current) if current == change.original_text => {}
|
||||
Ok(_) => {
|
||||
skipped.push(change.path.clone());
|
||||
continue;
|
||||
for (index, change) in changes.iter().enumerate() {
|
||||
let attempt = (|| {
|
||||
hook(index)?;
|
||||
if rollout_file_is_open(&change.path) {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error) if is_locked_io_error(&error) => {
|
||||
skipped.push(change.path.clone());
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
let original_error = io_err(&change.path, error);
|
||||
return match restore_session_changes(&applied) {
|
||||
Ok(()) => Err(original_error),
|
||||
Err(rollback_error) => Err(CodexxError::Config(format!(
|
||||
"{original_error};回滚失败:{rollback_error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
}
|
||||
match atomic_write(&change.path, change.next_text.as_bytes()) {
|
||||
Ok(()) => {
|
||||
restore_file_mtime(&change.path, change.original_mtime);
|
||||
applied.push(change.clone());
|
||||
match hash_rollout_file(&change.path) {
|
||||
Ok(current) if current == change.original_hash() => {}
|
||||
Ok(_) => return Ok(false),
|
||||
Err(error) if is_locked_io_error(&error) => return Ok(false),
|
||||
Err(error) => return Err(io_err(&change.path, error)),
|
||||
}
|
||||
write_session_change(change, false)?;
|
||||
restore_file_mtime(&change.path, change.original_mtime);
|
||||
Ok(true)
|
||||
})();
|
||||
match attempt {
|
||||
Ok(true) => applied.push(change.clone()),
|
||||
Ok(false) => skipped.push(change.path.clone()),
|
||||
Err(error) => {
|
||||
return match restore_session_changes(&applied) {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Config(format!(
|
||||
"{error};回滚失败:{rollback_error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,18 +562,11 @@ pub(crate) fn apply_session_changes(
|
||||
|
||||
pub(crate) fn restore_session_changes(changes: &[SessionFileChange]) -> Result<()> {
|
||||
let mut failed = 0usize;
|
||||
for change in changes {
|
||||
if rollout_file_is_open(&change.path) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
let unchanged =
|
||||
fs::read_to_string(&change.path).is_ok_and(|current| current == change.next_text);
|
||||
if !unchanged {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
if atomic_write(&change.path, change.original_text.as_bytes()).is_err() {
|
||||
for change in changes.iter().rev() {
|
||||
if rollout_file_is_open(&change.path)
|
||||
|| !hash_rollout_file(&change.path).is_ok_and(|hash| hash == change.next_hash())
|
||||
|| write_session_change(change, true).is_err()
|
||||
{
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1199,74 +1195,11 @@ pub(crate) fn source_text_is_subagent(source: &str) -> bool {
|
||||
#[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)]
|
||||
#[derive(Default)]
|
||||
struct RolloutIdentityPayload {
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
model_provider: Option<String>,
|
||||
source: InternalSource,
|
||||
thread_source: Option<String>,
|
||||
}
|
||||
@@ -1281,23 +1214,21 @@ impl RolloutIdentityPayload {
|
||||
}
|
||||
}
|
||||
|
||||
#[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())
|
||||
read_first_meta(text.as_bytes(), Path::new("inline rollout snapshot"))
|
||||
.is_ok_and(|record| record.is_internal)
|
||||
}
|
||||
|
||||
pub(super) fn rollout_path_has_syncable_identity(path: &Path) -> Result<bool> {
|
||||
read_rollout_identity(path)
|
||||
let file = fs::File::open(path).map_err(|error| io_err(path, error))?;
|
||||
read_rollout_identity_from_reader(file, path)
|
||||
.map(|record| !record.payload.is_internal())
|
||||
.map_err(CodexxError::Config)
|
||||
}
|
||||
@@ -1338,18 +1269,25 @@ fn rollout_filename_thread_id(path: &Path) -> Option<&str> {
|
||||
(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(|_| {
|
||||
fn read_rollout_identity_from_reader<R: Read>(
|
||||
reader: R,
|
||||
path: &Path,
|
||||
) -> std::result::Result<RolloutIdentityRecord, String> {
|
||||
let first = read_first_meta(reader, path).map_err(|error| {
|
||||
format!(
|
||||
"会话文件包含无法解析的 JSON 或无法读取会话文件来源信息: {}",
|
||||
"会话文件包含无法解析的 JSON 或无法读取会话文件来源信息: {} ({error})",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let record = RolloutIdentityRecord {
|
||||
kind: "session_meta".into(),
|
||||
payload: RolloutIdentityPayload {
|
||||
id: Some(first.id),
|
||||
model_provider: first.provider,
|
||||
source: InternalSource(first.is_internal),
|
||||
thread_source: None,
|
||||
},
|
||||
};
|
||||
if record.kind != "session_meta" {
|
||||
return Err(format!("会话文件缺少起始 session_meta: {}", path.display()));
|
||||
}
|
||||
@@ -1371,6 +1309,12 @@ fn read_rollout_identity(path: &Path) -> std::result::Result<RolloutIdentityReco
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn read_rollout_identity(path: &Path) -> std::result::Result<RolloutIdentityRecord, String> {
|
||||
let file = fs::File::open(path)
|
||||
.map_err(|_| format!("无法读取会话文件来源信息: {}", path.display()))?;
|
||||
read_rollout_identity_from_reader(file, path)
|
||||
}
|
||||
|
||||
fn is_authoritative_rollout(
|
||||
codex_dir: &Path,
|
||||
path: &Path,
|
||||
@@ -2074,6 +2018,252 @@ mod tests {
|
||||
assert!(scan.thread_ids.contains("fork"));
|
||||
assert!(!scan.thread_ids.contains("internal"));
|
||||
assert_eq!(scan.changes.len(), 1);
|
||||
let original_hash: [u8; 32] = Sha256::digest(fork_text.as_bytes()).into();
|
||||
assert_eq!(
|
||||
scan.verified_rollout_hashes.get(&scan.changes[0].path),
|
||||
Some(&original_hash)
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
const LEGACY_READ_LIMIT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
#[test]
|
||||
fn streaming_hash_uses_fixed_read_buffers_and_propagates_io_errors() {
|
||||
struct CountedReader {
|
||||
remaining: usize,
|
||||
largest: usize,
|
||||
}
|
||||
impl Read for CountedReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.largest = self.largest.max(buffer.len());
|
||||
let count = buffer.len().min(self.remaining);
|
||||
buffer[..count].fill(b'x');
|
||||
self.remaining -= count;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
let mut reader = CountedReader {
|
||||
remaining: 97 * 1024 * 1024,
|
||||
largest: 0,
|
||||
};
|
||||
hash_reader(&mut reader).unwrap();
|
||||
assert_eq!(reader.remaining, 0);
|
||||
assert_eq!(reader.largest, 64 * 1024);
|
||||
struct FailingReader;
|
||||
impl Read for FailingReader {
|
||||
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::Error::other("synthetic read failure"))
|
||||
}
|
||||
}
|
||||
assert!(hash_reader(FailingReader).is_err());
|
||||
}
|
||||
|
||||
fn padded_rollout(path: &Path, id: &str, provider: &str, size: u64) {
|
||||
let header = serde_json::json!({"type":"session_meta","payload":{"id":id,"model_provider":provider,"source":"vscode"}}).to_string() + "\n";
|
||||
let mut file = fs::File::create(path).unwrap();
|
||||
file.write_all(header.as_bytes()).unwrap();
|
||||
let padding = [b' '; 64 * 1024];
|
||||
let mut remaining = size - header.len() as u64;
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(padding.len() as u64) as usize;
|
||||
file.write_all(&padding[..count]).unwrap();
|
||||
remaining -= count as u64;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_scan_accepts_below_exact_and_above_previous_limit() {
|
||||
let dir = temp_codex_dir("streaming-rollout-boundaries");
|
||||
let sessions = dir.join("sessions");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
for (name, size) in [
|
||||
("below", LEGACY_READ_LIMIT - 1),
|
||||
("exact", LEGACY_READ_LIMIT),
|
||||
("above", LEGACY_READ_LIMIT + 1),
|
||||
] {
|
||||
padded_rollout(
|
||||
&sessions.join(format!("rollout-{name}.jsonl")),
|
||||
name,
|
||||
"custom",
|
||||
size,
|
||||
);
|
||||
}
|
||||
let scan =
|
||||
scan_provider_rollouts(&dir, "custom", &HashSet::new(), &HashMap::new()).unwrap();
|
||||
assert!(scan.scan_failures.is_empty(), "{:?}", scan.scan_failures);
|
||||
assert!(scan.blocked_failures.is_empty());
|
||||
assert_eq!(scan.thread_ids.len(), 3);
|
||||
assert_eq!(scan.verified_rollout_hashes.len(), 3);
|
||||
assert_eq!(scan.session_meta_count, 3);
|
||||
assert!(scan.changes.is_empty());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_large_user_files_fail_while_internal_and_excluded_files_are_ignored() {
|
||||
let dir = temp_codex_dir("streaming-invalid-rollouts");
|
||||
let sessions = dir.join("sessions");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
for (name, source) in [
|
||||
("user", "vscode"),
|
||||
("internal", "internal"),
|
||||
("excluded", "vscode"),
|
||||
] {
|
||||
let path = sessions.join(format!("rollout-{name}.jsonl"));
|
||||
let header = serde_json::json!({"type":"session_meta","payload":{"id":name,"model_provider":"openai","source":source}});
|
||||
fs::write(&path, format!("{header}\n")).unwrap();
|
||||
fs::File::options()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_READ_LIMIT + 1)
|
||||
.unwrap();
|
||||
}
|
||||
let scan = scan_provider_rollouts(
|
||||
&dir,
|
||||
"custom",
|
||||
&HashSet::from(["excluded".into()]),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scan.scan_failures.len(), 1);
|
||||
assert!(scan.blocked_failures.is_empty());
|
||||
assert!(scan.changes.is_empty());
|
||||
assert!(scan.verified_rollout_hashes.is_empty());
|
||||
assert_eq!(scan.internal_thread_ids, HashSet::from(["internal".into()]));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_expansion_above_previous_limit_is_staged_and_reversible() {
|
||||
let dir = temp_codex_dir("streaming-output-expansion");
|
||||
fs::create_dir_all(dir.join("sessions")).unwrap();
|
||||
let path = dir.join("sessions/rollout-expanded.jsonl");
|
||||
padded_rollout(&path, "expanded", "tiny", LEGACY_READ_LIMIT);
|
||||
let original_hash = hash_rollout_file(&path).unwrap();
|
||||
let original_mtime = fs::metadata(&path).unwrap().modified().unwrap();
|
||||
let scan = scan_provider_rollouts(
|
||||
&dir,
|
||||
"provider-longer-than-tiny",
|
||||
&HashSet::new(),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(scan.scan_failures.is_empty(), "{:?}", scan.scan_failures);
|
||||
assert_eq!(scan.changes.len(), 1);
|
||||
let change = &scan.changes[0];
|
||||
assert!(change.original_text.is_empty());
|
||||
assert!(change.next_text.is_empty());
|
||||
let staged = change.streamed.as_ref().unwrap();
|
||||
assert!(fs::metadata(&staged.next_path).unwrap().len() > LEGACY_READ_LIMIT);
|
||||
assert_eq!(hash_rollout_file(&path).unwrap(), original_hash);
|
||||
let (applied, skipped) = apply_session_changes(&scan.changes).unwrap();
|
||||
assert_eq!(applied.len(), 1);
|
||||
assert!(skipped.is_empty());
|
||||
assert_eq!(
|
||||
read_rollout_identity(&path)
|
||||
.unwrap()
|
||||
.payload
|
||||
.model_provider
|
||||
.as_deref(),
|
||||
Some("provider-longer-than-tiny")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().modified().unwrap(),
|
||||
original_mtime
|
||||
);
|
||||
restore_session_changes(&applied).unwrap();
|
||||
assert_eq!(hash_rollout_file(&path).unwrap(), original_hash);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_snapshots_do_not_accumulate_conversation_text_in_memory() {
|
||||
let dir = temp_codex_dir("streaming-snapshot-budget");
|
||||
fs::create_dir_all(dir.join("sessions")).unwrap();
|
||||
for index in 0..4 {
|
||||
let id = format!("budget-{index}");
|
||||
padded_rollout(
|
||||
&dir.join(format!("sessions/rollout-{id}.jsonl")),
|
||||
&id,
|
||||
"openai",
|
||||
22 * 1024 * 1024,
|
||||
);
|
||||
}
|
||||
let scan =
|
||||
scan_provider_rollouts(&dir, "custom", &HashSet::new(), &HashMap::new()).unwrap();
|
||||
assert!(scan.scan_failures.is_empty(), "{:?}", scan.scan_failures);
|
||||
assert!(scan.blocked_failures.is_empty());
|
||||
assert_eq!(scan.changes.len(), 4);
|
||||
assert_eq!(scan.provider_candidate_paths.len(), 4);
|
||||
assert_eq!(
|
||||
scan.changes
|
||||
.iter()
|
||||
.map(|c| c.original_text.capacity() + c.next_text.capacity())
|
||||
.sum::<usize>(),
|
||||
0
|
||||
);
|
||||
assert!(scan.changes.iter().all(|c| c.streamed.is_some()));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_and_restore_preserve_externally_grown_rollouts() {
|
||||
let dir = temp_codex_dir("streaming-apply-restore");
|
||||
let path = dir.join("rollout-grown.jsonl");
|
||||
fs::write(&path, "external fixture").unwrap();
|
||||
fs::File::options()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_READ_LIMIT + 1)
|
||||
.unwrap();
|
||||
let change = SessionFileChange {
|
||||
path: path.clone(),
|
||||
original_text: "external fixture".into(),
|
||||
next_text: "rewritten fixture".into(),
|
||||
original_mtime: None,
|
||||
streamed: None,
|
||||
};
|
||||
let (applied, skipped) = apply_session_changes(std::slice::from_ref(&change)).unwrap();
|
||||
assert!(applied.is_empty());
|
||||
assert_eq!(skipped, vec![path.clone()]);
|
||||
assert!(restore_session_changes(std::slice::from_ref(&change)).is_err());
|
||||
assert_eq!(fs::metadata(&path).unwrap().len(), LEGACY_READ_LIMIT + 1);
|
||||
assert!(ensure_rollout_snapshot_unchanged(&path, &change.original_hash()).is_err());
|
||||
assert!(rollout_path_has_syncable_identity(&path).is_err());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_disk_snapshot_cannot_replace_log_and_prior_changes_roll_back() {
|
||||
let dir = temp_codex_dir("streaming-staged-write-failure");
|
||||
fs::create_dir_all(dir.join("sessions")).unwrap();
|
||||
for id in ["a", "b"] {
|
||||
padded_rollout(
|
||||
&dir.join(format!("sessions/rollout-{id}.jsonl")),
|
||||
id,
|
||||
"openai",
|
||||
1024,
|
||||
);
|
||||
}
|
||||
let scan =
|
||||
scan_provider_rollouts(&dir, "custom", &HashSet::new(), &HashMap::new()).unwrap();
|
||||
let original_hashes: Vec<_> = scan
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| (c.path.clone(), c.original_hash()))
|
||||
.collect();
|
||||
fs::write(
|
||||
&scan.changes[1].streamed.as_ref().unwrap().next_path,
|
||||
b"corrupted snapshot",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(apply_session_changes(&scan.changes).is_err());
|
||||
for (path, hash) in original_hashes {
|
||||
assert_eq!(hash_rollout_file(&path).unwrap(), hash);
|
||||
}
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
|
||||
use super::catalog::{scan_catalog_internal_thread_ids, scan_catalog_sync, CatalogSyncScan};
|
||||
use super::directory_identity::session_directory_identities;
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
list_session_previews_with_paths, rollout_internal_thread_ids, scan_provider_rollouts,
|
||||
@@ -127,10 +128,15 @@ fn scan_provider_sync_data(
|
||||
sqlite.mismatched_thread_ids = mismatched_thread_ids;
|
||||
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
|
||||
|
||||
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
// Only diagnostics from this authority scan are needed below. Drop its
|
||||
// planned file copies before scanning the complete provider history.
|
||||
let (indexed_rollout_failures, indexed_blocked_failures) = {
|
||||
let rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
};
|
||||
(rollouts.scan_failures, rollouts.blocked_failures)
|
||||
};
|
||||
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
|
||||
let mut rollouts = scan_provider_rollouts(
|
||||
@@ -169,29 +175,24 @@ fn scan_provider_sync_data(
|
||||
let mut scan_failures = discovery.active_scan_failures.clone();
|
||||
scan_failures.extend(active_sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_rollouts.scan_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_rollout_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_blocked_failures);
|
||||
// A size/buffer limit means the rollout was not completely inspected.
|
||||
// This is blocking even for an orphan, rather than a success with a warning.
|
||||
scan_failures.extend(rollouts.blocked_failures.iter().cloned());
|
||||
// A failed eligible rollout read/parse is an incomplete check, including
|
||||
// unindexed files. It must not become an apparently successful sync.
|
||||
scan_failures.extend(rollouts.scan_failures.iter().cloned());
|
||||
for path in &discovery.unreadable_paths {
|
||||
scan_failures.push(format!("无法读取会话数据库: {}", path.display()));
|
||||
}
|
||||
|
||||
let indexed_failures = indexed_rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.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);
|
||||
warnings.extend(
|
||||
rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.filter(|failure| !indexed_failures.contains(*failure))
|
||||
.map(|failure| format!("已跳过未进入会话索引的异常文件:{failure}")),
|
||||
);
|
||||
let mut seen = HashSet::new();
|
||||
scan_failures.retain(|failure| seen.insert(failure.clone()));
|
||||
seen.clear();
|
||||
@@ -245,8 +246,12 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
Err(error) => {
|
||||
let mut failures = scan.scan_failures.clone();
|
||||
failures.push(format!("无法读取当前活动会话列表: {error}"));
|
||||
let (directory_identities, pin_scope_key) =
|
||||
session_directory_identities(codex_dir, &[]);
|
||||
return Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
directory_identities,
|
||||
pin_scope_key: Some(pin_scope_key),
|
||||
target_provider: target,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
@@ -284,10 +289,14 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
&& mismatched_ids.contains(&session.id);
|
||||
}
|
||||
let needs_sync = !scan.rollouts.changes.is_empty()
|
||||
|| !scan.rollouts.mismatched_thread_ids.is_empty()
|
||||
|| scan.sqlite.mismatched_threads > 0
|
||||
|| scan.catalog.total_updates() > 0;
|
||||
let (directory_identities, pin_scope_key) = session_directory_identities(codex_dir, &sessions);
|
||||
Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
directory_identities,
|
||||
pin_scope_key: Some(pin_scope_key),
|
||||
target_provider: target,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
@@ -391,6 +400,7 @@ where
|
||||
backup_dir: String::new(),
|
||||
});
|
||||
}
|
||||
drop(preflight_scan);
|
||||
|
||||
hook(MutationPoint::BeforeSqliteLock)?;
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
|
||||
@@ -411,6 +421,14 @@ where
|
||||
{
|
||||
rollback_open_transactions(&mut pending_sqlite);
|
||||
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
|
||||
if !status.scan_complete {
|
||||
return Err(scan_failure_error(&status.scan_failures));
|
||||
}
|
||||
if status.needs_sync {
|
||||
return Err(CodexxError::Config(
|
||||
"会话状态在检查后发生变化,未完成同步;请重新检查。".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: 0,
|
||||
@@ -458,25 +476,32 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
|
||||
let mut status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)
|
||||
.and_then(|status| {
|
||||
if !status.scan_complete {
|
||||
return Err(scan_failure_error(&status.scan_failures));
|
||||
}
|
||||
if status.needs_sync {
|
||||
return Err(CodexxError::Config(
|
||||
"会话日志与索引仍不一致,未完成同步;请重新检查。".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(status)
|
||||
})
|
||||
.map_err(|error| {
|
||||
CodexxError::Config(format!(
|
||||
"同步已完成,但刷新会话列表失败,请重新进入页面:{error}"
|
||||
))
|
||||
let recovery_errors = rollback_mutation(&journal, &mut pending_sqlite);
|
||||
mutation_error(
|
||||
CodexxError::Config(format!("同步后复查未通过,本轮同步未完成:{error}")),
|
||||
recovery_errors,
|
||||
)
|
||||
})?;
|
||||
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
|
||||
status.backup_dir = Some(backup.dir.display().to_string());
|
||||
if prune_warning.is_some() {
|
||||
status
|
||||
.warnings
|
||||
.push("同步已完成,但旧备份暂未清理。".to_string());
|
||||
}
|
||||
if !mutation.skipped_rollouts.is_empty() {
|
||||
status.warnings.push(format!(
|
||||
"有 {} 个会话正在使用,已跳过;退出 Codex 后再同步即可。",
|
||||
mutation.skipped_rollouts.len()
|
||||
));
|
||||
}
|
||||
Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: mutation.applied_rollouts,
|
||||
@@ -489,9 +514,12 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{BufRead, Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const SHARED_SESSION_PROVIDER: &str = "custom";
|
||||
const LEGACY_LIMIT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -676,6 +704,383 @@ mod tests {
|
||||
.expect("write rollout");
|
||||
}
|
||||
|
||||
fn pad_rollout_to_size(path: &Path, size: u64) {
|
||||
let mut file = fs::OpenOptions::new().append(true).open(path).unwrap();
|
||||
let mut remaining = size.checked_sub(file.metadata().unwrap().len()).unwrap();
|
||||
let padding = [b' '; 64 * 1024];
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(padding.len() as u64) as usize;
|
||||
file.write_all(&padding[..count]).unwrap();
|
||||
remaining -= count as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn rollout_digest(path: &Path) -> Vec<u8> {
|
||||
rollout_digest_from_offset(path, 0)
|
||||
}
|
||||
|
||||
fn rollout_digest_from_offset(path: &Path, offset: u64) -> Vec<u8> {
|
||||
let mut file = fs::File::open(path).unwrap();
|
||||
file.seek(SeekFrom::Start(offset)).unwrap();
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer).unwrap();
|
||||
if count == 0 {
|
||||
return digest.finalize().to_vec();
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_chat_record(path: &Path) {
|
||||
fs::OpenOptions::new().append(true).open(path).unwrap()
|
||||
.write_all(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"keep this conversation\"}}\n")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn read_first_rollout_record(path: &Path) -> String {
|
||||
let mut first_record = String::new();
|
||||
std::io::BufReader::new(fs::File::open(path).unwrap())
|
||||
.read_line(&mut first_record)
|
||||
.unwrap();
|
||||
first_record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_rollouts_below_at_and_above_legacy_limit_sync_logs_databases_and_catalogs() {
|
||||
for (name, size) in [
|
||||
("normal", 512),
|
||||
("below-old-limit", LEGACY_LIMIT - 1),
|
||||
("exact-old-limit", LEGACY_LIMIT),
|
||||
("above-old-limit", LEGACY_LIMIT + 1),
|
||||
] {
|
||||
let dir = temp_codex_dir(name);
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000701";
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let old_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
append_chat_record(&rollout);
|
||||
pad_rollout_to_size(&rollout, size);
|
||||
let conversation_before = rollout_digest_from_offset(&rollout, old_record_len);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(status.scan_complete, "{name}: {:?}", status.scan_failures);
|
||||
assert!(status.needs_sync, "{name}");
|
||||
let result =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1, "{name}");
|
||||
assert_eq!(thread_provider(&database, id), "custom", "{name}");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom", "{name}");
|
||||
let first_record = read_first_rollout_record(&rollout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollout).unwrap().len(), size, "{name}");
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollout, first_record.len() as u64),
|
||||
conversation_before,
|
||||
"conversation bytes must be preserved: {name}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_ninety_seven_mib_single_chat_line_syncs_without_losing_conversation_bytes() {
|
||||
let dir = temp_codex_dir("streamed-97-mib-chat-record");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000709";
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
let total_size = 97 * 1024 * 1024u64;
|
||||
let prefix =
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"";
|
||||
let suffix = b"\"}}\n";
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&rollout).unwrap();
|
||||
file.write_all(prefix).unwrap();
|
||||
let mut remaining =
|
||||
total_size - first_record_len - prefix.len() as u64 - suffix.len() as u64;
|
||||
let body_chunk = [b'x'; 64 * 1024];
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(body_chunk.len() as u64) as usize;
|
||||
file.write_all(&body_chunk[..count]).unwrap();
|
||||
remaining -= count as u64;
|
||||
}
|
||||
file.write_all(suffix).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
let conversation_before = rollout_digest_from_offset(&rollout, first_record_len);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
|
||||
.expect("a large valid single JSONL record must synchronize successfully");
|
||||
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom");
|
||||
let first_record = read_first_rollout_record(&rollout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollout).unwrap().len(), total_size);
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollout, first_record.len() as u64),
|
||||
conversation_before,
|
||||
"the large chat record must retain every original byte"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_multiple_rollouts_exceeding_legacy_snapshot_budget_sync_together() {
|
||||
let dir = temp_codex_dir("streamed-multi-large-rollout-budget");
|
||||
write_config(&dir, "custom");
|
||||
let ids = [
|
||||
"019f6000-0000-7000-8000-000000000711",
|
||||
"019f6000-0000-7000-8000-000000000712",
|
||||
"019f6000-0000-7000-8000-000000000713",
|
||||
];
|
||||
let file_size = 45 * 1024 * 1024u64;
|
||||
assert!(file_size * ids.len() as u64 > 128 * 1024 * 1024);
|
||||
let mut rollouts = Vec::new();
|
||||
let mut conversation_digests = Vec::new();
|
||||
for id in ids {
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
append_chat_record(&rollout);
|
||||
pad_rollout_to_size(&rollout, file_size);
|
||||
conversation_digests.push(rollout_digest_from_offset(&rollout, first_record_len));
|
||||
rollouts.push(rollout);
|
||||
}
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, ids[0], "openai", &rollouts[0]);
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
for index in 1..ids.len() {
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'large history', ?2)",
|
||||
(ids[index], rollouts[index].display().to_string()),
|
||||
).unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &ids.map(|id| (id, "openai")));
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
|
||||
.expect("the combined size of valid rollouts must not block synchronization");
|
||||
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, ids.len());
|
||||
for index in 0..ids.len() {
|
||||
assert_eq!(thread_provider(&database, ids[index]), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, ids[index]), "custom");
|
||||
let first_record = read_first_rollout_record(&rollouts[index]);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollouts[index]).unwrap().len(), file_size);
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollouts[index], first_record.len() as u64),
|
||||
conversation_digests[index],
|
||||
"conversation bytes must remain unchanged for {}",
|
||||
ids[index]
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_invalid_jsonl_rollout_reports_parse_failure_and_keeps_indexes_unchanged() {
|
||||
for (name, file_provider, database_provider) in [
|
||||
("file-and-index-old", "openai", "openai"),
|
||||
("index-already-target", "openai", "custom"),
|
||||
("first-record-matches", "custom", "custom"),
|
||||
] {
|
||||
let dir = temp_codex_dir(name);
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000702";
|
||||
let rollout = write_rollout(&dir, id, file_provider);
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&rollout)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
let before = rollout_digest(&rollout);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, database_provider, &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, database_provider)]);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete, "{name}");
|
||||
assert!(
|
||||
status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("JSONL")),
|
||||
"{name}: {:?}",
|
||||
status.scan_failures
|
||||
);
|
||||
let error =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert!(error.to_string().contains("JSONL"), "{name}: {error}");
|
||||
assert_eq!(thread_provider(&database, id), database_provider, "{name}");
|
||||
assert_eq!(catalog_provider(&catalog, id), database_provider, "{name}");
|
||||
assert_eq!(rollout_digest(&rollout), before, "{name}");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_invalid_orphan_blocks_sync_without_changing_normal_logs_or_indexes() {
|
||||
let dir = temp_codex_dir("large-invalid-orphan-blocked");
|
||||
write_config(&dir, "custom");
|
||||
let ordinary_id = "019f6000-0000-7000-8000-000000000703";
|
||||
let ordinary = write_rollout(&dir, ordinary_id, "openai");
|
||||
let before = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, ordinary_id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(ordinary_id, "openai")]);
|
||||
let orphan_id = "019f6000-0000-7000-8000-000000000704";
|
||||
let invalid_orphan = write_rollout(&dir, orphan_id, "openai");
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&invalid_orphan)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
let orphan_before = rollout_digest(&invalid_orphan);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete && status.needs_sync);
|
||||
let error =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert!(error.to_string().contains("JSONL"), "{error}");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), before);
|
||||
assert_eq!(rollout_digest(&invalid_orphan), orphan_before);
|
||||
assert_eq!(thread_provider(&database, ordinary_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, ordinary_id), "openai");
|
||||
assert!(catalog_provider_and_visibility(&catalog, orphan_id).is_none());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_orphan_read_or_parse_cannot_be_reported_as_successful_sync() {
|
||||
for suffix in [b"\xff".as_slice(), b"not-json\n".as_slice()] {
|
||||
let dir = temp_codex_dir("failed-orphan-check-blocks-sync");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000705";
|
||||
let ordinary = write_rollout(&dir, id, "openai");
|
||||
let original = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
let orphan = write_rollout(&dir, "019f6000-0000-7000-8000-000000000706", "openai");
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&orphan)
|
||||
.unwrap()
|
||||
.write_all(suffix)
|
||||
.unwrap();
|
||||
let orphan_original = fs::read(&orphan).unwrap();
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete && !status.scan_failures.is_empty());
|
||||
assert!(sync_sessions_provider_inner(Some(dir.display().to_string()), None).is_err());
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, id), "openai");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), original);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), orphan_original);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_final_recheck_rolls_back_committed_logs_and_indexes() {
|
||||
for scenario in ["invalid", "large-invalid", "new-mismatch"] {
|
||||
let dir = temp_codex_dir("failed-final-recheck");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000707";
|
||||
let ordinary = write_rollout(&dir, id, "openai");
|
||||
let original = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
let late = dir.join("sessions/rollout-test-019f6000-0000-7000-8000-000000000708.jsonl");
|
||||
let mut late_digest = None;
|
||||
|
||||
let error =
|
||||
sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
|
||||
if matches!(point, MutationPoint::AfterSqliteCommit(_)) && !late.exists() {
|
||||
if scenario == "invalid" {
|
||||
fs::write(&late, b"\xff").unwrap();
|
||||
} else {
|
||||
write_rollout_at(
|
||||
&late,
|
||||
"019f6000-0000-7000-8000-000000000708",
|
||||
"openai",
|
||||
);
|
||||
if scenario == "large-invalid" {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&late)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
late_digest = Some(rollout_digest(&late));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a failed final check must not return a successful sync");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("同步后复查未通过"),
|
||||
"{scenario}: {error}"
|
||||
);
|
||||
assert_eq!(thread_provider(&database, id), "openai", "{scenario}");
|
||||
assert_eq!(catalog_provider(&catalog, id), "openai", "{scenario}");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), original, "{scenario}");
|
||||
assert_eq!(
|
||||
rollout_digest(&late),
|
||||
late_digest.unwrap(),
|
||||
"preserve external file: {scenario}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subagent_rollout(
|
||||
codex_dir: &Path,
|
||||
id: &str,
|
||||
@@ -975,7 +1380,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_standard_orphan_rollout_does_not_block_active_sessions() {
|
||||
fn malformed_standard_orphan_keeps_check_incomplete_without_mutation() {
|
||||
let codex_dir = temp_codex_dir("malformed-standard-orphan");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000502";
|
||||
@@ -985,26 +1390,30 @@ mod tests {
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl")),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid orphan rollout");
|
||||
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
let active_before = fs::read(&active).unwrap();
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let orphan = codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl"));
|
||||
fs::write(&orphan, b"\xff").expect("write invalid orphan rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan active sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.scan_failures.is_empty());
|
||||
assert_eq!(status.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
|
||||
assert_eq!(fs::read(&active).unwrap(), active_before);
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_unreferenced_nonstandard_rollout_does_not_block_active_sessions() {
|
||||
fn malformed_unreferenced_rollout_keeps_check_incomplete_without_mutation() {
|
||||
let codex_dir = temp_codex_dir("malformed-unreferenced-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000504";
|
||||
@@ -1013,19 +1422,23 @@ mod tests {
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join("sessions/rollout-imported-orphan.jsonl"),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid unreferenced rollout");
|
||||
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
let active_before = fs::read(&active).unwrap();
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let orphan = codex_dir.join("sessions/rollout-imported-orphan.jsonl");
|
||||
fs::write(&orphan, b"\xff").expect("write invalid unreferenced rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan referenced sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.scan_failures.is_empty());
|
||||
assert_eq!(status.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
|
||||
assert_eq!(fs::read(&active).unwrap(), active_before);
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::catalog::{
|
||||
restore_catalog_updates, CatalogRepairThread,
|
||||
};
|
||||
use super::storage::{
|
||||
apply_session_changes, restore_session_changes, rollout_path_has_syncable_identity,
|
||||
rollout_text_is_internal, sqlite_subagent_thread_ids,
|
||||
apply_session_changes_with_hook, hash_rollout_file, 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};
|
||||
@@ -37,6 +37,7 @@ impl SqliteUpdateCounts {
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct MutationJournal {
|
||||
applied_rollouts: Vec<SessionFileChange>,
|
||||
expected_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
|
||||
}
|
||||
|
||||
@@ -285,6 +286,7 @@ where
|
||||
{
|
||||
let mut updated = SqliteUpdateCounts::default();
|
||||
for index in 0..pending.len() {
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let before_commit = sqlite_data_version(&pending[index].observer)?;
|
||||
journal.sqlite_restore_attempts.push(SqliteRestoreAttempt {
|
||||
path: pending[index].path.clone(),
|
||||
@@ -311,6 +313,9 @@ where
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// The final commit hook can publish an external rollout update as well.
|
||||
// Returning success requires the complete candidate set to remain verified.
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -418,13 +423,13 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
pub(super) enum MutationPoint {
|
||||
BeforeSqliteLock,
|
||||
BeforeRolloutMutation,
|
||||
BeforeEachRolloutMutation(usize),
|
||||
AfterRolloutMutation,
|
||||
AfterSqliteCommit(usize),
|
||||
}
|
||||
|
||||
pub(super) struct MutationResult {
|
||||
pub(super) applied_rollouts: usize,
|
||||
pub(super) skipped_rollouts: Vec<PathBuf>,
|
||||
pub(super) sqlite_updates: SqliteUpdateCounts,
|
||||
}
|
||||
|
||||
@@ -434,7 +439,12 @@ pub(super) struct MutationResult {
|
||||
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) {
|
||||
let original_is_internal = if let Some(snapshot) = &change.streamed {
|
||||
!rollout_path_has_syncable_identity(&snapshot.original_path)?
|
||||
} else {
|
||||
rollout_text_is_internal(&change.original_text)
|
||||
};
|
||||
if original_is_internal {
|
||||
return Err(CodexxError::Config(
|
||||
"内部会话不能同步为普通会话。".to_string(),
|
||||
));
|
||||
@@ -452,6 +462,46 @@ fn validate_rollout_classification(rollouts: &RolloutScan) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expected_rollout_hashes(rollouts: &RolloutScan) -> Result<HashMap<PathBuf, [u8; 32]>> {
|
||||
let mut expected = HashMap::new();
|
||||
for path in &rollouts.provider_candidate_paths {
|
||||
let hash = rollouts.verified_rollout_hashes.get(path).ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"缺少会话文件验证快照,已停止同步:{}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
expected.insert(path.clone(), *hash);
|
||||
}
|
||||
for change in &rollouts.changes {
|
||||
let original_hash = change.original_hash();
|
||||
if expected
|
||||
.get(&change.path)
|
||||
.is_some_and(|hash| *hash != original_hash)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件改写快照与扫描结果不一致,已停止同步:{}",
|
||||
change.path.display()
|
||||
)));
|
||||
}
|
||||
// A planned change already carries the scan's verified original snapshot.
|
||||
expected.insert(change.path.clone(), original_hash);
|
||||
}
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
fn validate_rollout_snapshots(expected: &HashMap<PathBuf, [u8; 32]>) -> Result<()> {
|
||||
for (path, expected_hash) in expected {
|
||||
if hash_rollout_file(path).map_err(|error| io_err(path, error))? != *expected_hash {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件已发生变化,已停止同步;请重新检查会话:{}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
@@ -465,12 +515,35 @@ where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
if !rollouts.blocked_failures.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话扫描未完成,已停止同步:{}",
|
||||
rollouts.blocked_failures.join(";")
|
||||
)));
|
||||
}
|
||||
journal.expected_rollout_hashes = expected_rollout_hashes(rollouts)?;
|
||||
hook(MutationPoint::BeforeRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let (applied_rollouts, skipped_rollouts) =
|
||||
apply_session_changes_with_hook(&rollouts.changes, |index| {
|
||||
hook(MutationPoint::BeforeEachRolloutMutation(index))
|
||||
})?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
for change in &journal.applied_rollouts {
|
||||
journal
|
||||
.expected_rollout_hashes
|
||||
.insert(change.path.clone(), change.next_hash());
|
||||
}
|
||||
if !skipped_rollouts.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"有 {} 个会话文件被占用或已发生变化,已停止同步;请退出 Codex 后重新检查会话。",
|
||||
skipped_rollouts.len()
|
||||
)));
|
||||
}
|
||||
hook(MutationPoint::AfterRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
apply_sqlite_updates(
|
||||
pending_sqlite,
|
||||
rollouts,
|
||||
@@ -481,7 +554,6 @@ where
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
skipped_rollouts,
|
||||
sqlite_updates,
|
||||
})
|
||||
})();
|
||||
|
||||
@@ -2,13 +2,16 @@ use super::*;
|
||||
use crate::sessions::backup::provider_sync_backup_root;
|
||||
use crate::sessions::sync::sync_sessions_provider_with_hook;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const LARGE_ROLLOUT_TEST_BYTES: u64 = 33 * 1024 * 1024;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -73,6 +76,633 @@ fn sqlite_quick_check(path: &Path) -> String {
|
||||
.expect("run quick check")
|
||||
}
|
||||
|
||||
struct RolloutFailureFixture {
|
||||
codex_dir: PathBuf,
|
||||
ids: [&'static str; 2],
|
||||
rollouts: [PathBuf; 2],
|
||||
originals: [Vec<u8>; 2],
|
||||
database: PathBuf,
|
||||
catalog: PathBuf,
|
||||
}
|
||||
|
||||
impl RolloutFailureFixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let codex_dir = temp_dir(name);
|
||||
let ids = [
|
||||
"019f6000-0000-7000-8000-000000000991",
|
||||
"019f6000-0000-7000-8000-000000000992",
|
||||
];
|
||||
let rollouts = ids.map(|id| codex_dir.join(format!("sessions/rollout-test-{id}.jsonl")));
|
||||
let originals = std::array::from_fn(|index| write_rollout(&rollouts[index], ids[index]));
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, ids[0], &rollouts[0]);
|
||||
Connection::open(&database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
|
||||
(ids[1], rollouts[1].display().to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
fs::create_dir_all(catalog.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(&catalog).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE local_thread_catalog (
|
||||
host_id TEXT NOT NULL, thread_id TEXT NOT NULL, model_provider TEXT NOT NULL,
|
||||
PRIMARY KEY (host_id, thread_id));
|
||||
CREATE TABLE local_thread_catalog_metadata (
|
||||
id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL);
|
||||
INSERT INTO local_thread_catalog_metadata VALUES (1, 7);",
|
||||
)
|
||||
.unwrap();
|
||||
for id in ids {
|
||||
conn.execute(
|
||||
"INSERT INTO local_thread_catalog VALUES ('local', ?1, 'openai')",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Self {
|
||||
codex_dir,
|
||||
ids,
|
||||
rollouts,
|
||||
originals,
|
||||
database,
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&self) -> RolloutScan {
|
||||
let changes = (0..2)
|
||||
.map(|index| {
|
||||
let original_text = String::from_utf8(self.originals[index].clone()).unwrap();
|
||||
SessionFileChange {
|
||||
path: self.rollouts[index].clone(),
|
||||
next_text: original_text.replace("\"openai\"", "\"custom\""),
|
||||
original_text,
|
||||
original_mtime: None,
|
||||
streamed: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
RolloutScan {
|
||||
changes,
|
||||
provider_candidate_paths: self.rollouts.iter().cloned().collect(),
|
||||
verified_rollout_hashes: (0..2)
|
||||
.map(|index| {
|
||||
(
|
||||
self.rollouts[index].clone(),
|
||||
Sha256::digest(&self.originals[index]).into(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..RolloutScan::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_rollouts_to_target_provider(&mut self) {
|
||||
for index in 0..2 {
|
||||
self.originals[index] = String::from_utf8(self.originals[index].clone())
|
||||
.unwrap()
|
||||
.replace("\"openai\"", "\"custom\"")
|
||||
.into_bytes();
|
||||
fs::write(&self.rollouts[index], &self.originals[index]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_provider(&self, id: &str) -> String {
|
||||
Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT model_provider FROM local_thread_catalog WHERE thread_id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_databases_unchanged(&self) {
|
||||
for id in self.ids {
|
||||
assert_eq!(thread_provider(&self.database, id), "openai");
|
||||
assert_eq!(self.catalog_provider(id), "openai");
|
||||
}
|
||||
let (rows, revision): (i64, i64) = Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT (SELECT COUNT(*) FROM local_thread_catalog),
|
||||
(SELECT catalog_revision FROM local_thread_catalog_metadata WHERE id = 1)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((rows, revision), (2, 7));
|
||||
}
|
||||
|
||||
fn sync_with_hook<F>(&self, hook: F) -> Result<super::super::types::SessionSyncResult>
|
||||
where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
sync_sessions_provider_with_hook(Some(self.codex_dir.display().to_string()), None, hook)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RolloutFailureFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.codex_dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_rollout_scan_aborts_before_hooks_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-blocked-scan");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts
|
||||
.blocked_failures
|
||||
.push("会话文件读取失败,未完成扫描".to_string());
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("blocked scan must fail");
|
||||
assert!(error.to_string().contains("读取失败"));
|
||||
assert!(!hook_called);
|
||||
assert!(journal.applied_rollouts.is_empty());
|
||||
assert!(rollback_mutation(&journal, &mut pending).is_empty());
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_rollout_change_is_rejected_before_file_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-skip-rollback");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-new-data\"}}\n",
|
||||
);
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("any skipped rollout must abort the full sync");
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_rollout_after_first_write_rolls_back_and_never_updates_indexes() {
|
||||
let fixture = RolloutFailureFixture::new("streamed-skip-after-write");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-external-data\"}}\n",
|
||||
);
|
||||
let mut first_write_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
first_write_observed = fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a skipped second rollout must fail and roll back the first write");
|
||||
assert!(first_write_observed);
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_scan_aborts_without_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("unreadable full rollout must fail the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rollout_atomic_write_failure_restores_earlier_files_without_database_updates() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut fixture = RolloutFailureFixture::new("streaming-write-failure");
|
||||
let blocked_parent = fixture.codex_dir.join("sessions/zz-write-blocked");
|
||||
fs::create_dir_all(&blocked_parent).unwrap();
|
||||
let second_path = blocked_parent.join(fixture.rollouts[1].file_name().unwrap());
|
||||
fs::rename(&fixture.rollouts[1], &second_path).unwrap();
|
||||
fixture.rollouts[1] = second_path;
|
||||
Connection::open(&fixture.database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE threads SET rollout_path = ?1 WHERE id = ?2",
|
||||
(fixture.rollouts[1].display().to_string(), fixture.ids[1]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut reached_file_mutation = false;
|
||||
let result = fixture.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
reached_file_mutation = true;
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o500)).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
// Always restore the temporary fixture permissions before assertions/drop.
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let error = result.expect_err("temporary rollout write permission failure must fail sync");
|
||||
assert!(
|
||||
reached_file_mutation,
|
||||
"failure must occur after scan and backup"
|
||||
);
|
||||
assert!(error.to_string().contains("IO error"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_provider_rewrite_after_mutation_aborts_without_database_or_catalog_changes() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-provider-race");
|
||||
let external = fixture.originals[1].clone();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
assert!(fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
assert!(fs::read_to_string(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("ordinary provider change after writes must fail sync");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_snapshot_is_rechecked_before_each_database_commit() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-between-commits");
|
||||
let mut first_committed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(0) {
|
||||
first_committed = true;
|
||||
assert!(
|
||||
thread_provider(&fixture.database, fixture.ids[0]) == "custom"
|
||||
|| fixture.catalog_provider(fixture.ids[0]) == "custom"
|
||||
);
|
||||
fs::write(&fixture.rollouts[1], &fixture.originals[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("new rollout data before the second commit must abort and restore the first");
|
||||
assert!(first_committed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_scan_is_not_overwritten_or_reported_as_success() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-before-mutation");
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("concurrent growth must invalidate the verified snapshot");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_file_writes_preserves_external_data_and_rolls_back_other_files() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-after-mutation");
|
||||
let marker = b"keep-external-growth";
|
||||
let marker_offset = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let enlarged_len = marker_offset + marker.len() as u64;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
let mut external = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
external.set_len(enlarged_len).unwrap();
|
||||
external.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
external.write_all(marker).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("growth after writes must prevent all database commits");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut preserved = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
preserved.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
let mut restored_marker = vec![0; marker.len()];
|
||||
preserved.read_exact(&mut restored_marker).unwrap();
|
||||
assert_eq!(restored_marker, marker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_writes_restores_other_files_and_preserves_external_content() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure-after-writes");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("full snapshot read failure after writes must abort the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_without_verified_hash_is_rejected_before_mutation() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-unverified-candidate");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts.changes.clear();
|
||||
rollouts.verified_rollout_hashes.clear();
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("unverified candidate must not take a fresh mutable file as its baseline");
|
||||
assert!(
|
||||
error.to_string().contains("缺少会话文件验证快照"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!hook_called);
|
||||
fixture.assert_databases_unchanged();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidates_normally_repair_databases_without_rewriting_rollouts() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-normal-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("matched verified logs permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
thread_provider(&fixture.database, fixture.ids[index]),
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[index]), "custom");
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_unchanged_candidate_repairs_databases_without_rewriting_the_file() {
|
||||
let mut fixture = RolloutFailureFixture::new("streamed-large-matched-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let event = b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"unchanged large history\"}}\n";
|
||||
let chunk = event.repeat(512);
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
let mut original_len = file.metadata().unwrap().len();
|
||||
while original_len <= LARGE_ROLLOUT_TEST_BYTES {
|
||||
file.write_all(&chunk).unwrap();
|
||||
original_len += chunk.len() as u64;
|
||||
}
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
let original_hash = hash_rollout_file(&fixture.rollouts[1]).unwrap();
|
||||
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("large matched files permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for id in fixture.ids {
|
||||
assert_eq!(thread_provider(&fixture.database, id), "custom");
|
||||
assert_eq!(fixture.catalog_provider(id), "custom");
|
||||
}
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
original_len
|
||||
);
|
||||
assert_eq!(
|
||||
hash_rollout_file(&fixture.rollouts[1]).unwrap(),
|
||||
original_hash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_provider_changes_before_or_after_mutation_block_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-provider-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must not permit database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_growth_before_or_after_mutation_blocks_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-growth-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must prevent database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut external = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
let mut prefix = vec![0; fixture.originals[1].len()];
|
||||
external.read_exact(&mut prefix).unwrap();
|
||||
assert_eq!(prefix, fixture.originals[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_change_after_final_commit_rolls_back_databases_and_preserves_external_log() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-final-commit-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let mut last_commit_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(1) {
|
||||
last_commit_observed = true;
|
||||
assert_eq!(thread_provider(&fixture.database, fixture.ids[0]), "custom");
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[0]), "custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("the final commit hook must not bypass the complete snapshot validation");
|
||||
assert!(last_commit_observed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_update_failure_rolls_back_every_open_database_and_jsonl() {
|
||||
let codex_dir = temp_dir("multi-sqlite-update-failure");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -23,6 +25,10 @@ pub(crate) struct SessionPreview {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SessionSyncStatus {
|
||||
pub(crate) codex_dir: String,
|
||||
#[serde(default)]
|
||||
pub(crate) directory_identities: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub(crate) pin_scope_key: Option<String>,
|
||||
pub(crate) target_provider: String,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
@@ -60,6 +66,9 @@ pub(crate) struct RolloutScan {
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) provider_candidate_paths: HashSet<PathBuf>,
|
||||
/// SHA-256 of every completely validated candidate's original JSONL. Keep
|
||||
/// matched rollouts covered without retaining another copy of their text.
|
||||
pub(crate) verified_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
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
|
||||
@@ -68,6 +77,9 @@ pub(crate) struct RolloutScan {
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
/// Incomplete user-rollout scans that must block Provider/index mutations,
|
||||
/// including unindexed files that would otherwise become only warnings.
|
||||
pub(crate) blocked_failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -76,6 +88,24 @@ pub(crate) struct SessionFileChange {
|
||||
pub(crate) original_text: String,
|
||||
pub(crate) next_text: String,
|
||||
pub(crate) original_mtime: Option<SystemTime>,
|
||||
// Production snapshots live on disk; inline text is retained for small fixtures.
|
||||
pub(super) streamed: Option<Arc<super::rollout_stream::StreamedSnapshot>>,
|
||||
}
|
||||
|
||||
impl SessionFileChange {
|
||||
pub(super) fn original_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.original_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.original_hash,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn next_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.next_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.next_hash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"testOnly": true,
|
||||
"fixturePurpose": "Synthetic fixture for cfg(test) Tauri SDK download and Minisign verification. The 256-byte PE header contains no executable code, sections or entry point. Never install or run it. This public key must never replace the production updater public key. Its private key was generated only in memory and was not saved.",
|
||||
"expectedVersion": "0.3.24",
|
||||
"publicKeyBase64": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDMxOUI5MzI2MzA2QkQ5NUMKUldSYzJXc3dKcE9iTWZrYjhWL1VCZXZ0TWFBNE9IWE9zTENncU5LSFFKaW9wblF4c3VtQXVSSVMK",
|
||||
"signatureBase64": "dW50cnVzdGVkIGNvbW1lbnQ6IHN5bnRoZXRpYyB0ZXN0IHNpZ25hdHVyZTsgbmV2ZXIgdXNlIGZvciBwcm9kdWN0aW9uIHVwZGF0ZXMKUlVSYzJXc3dKcE9iTWRZZ0xROVZ0T3c1SXVuWXYvU3FUbkRrNGowMjNId2FhcnhTeFpIa25DUm80bGdINzd4UEYvcE1mRWxKUHJZeVczQzBiNHlwWjJYZ3Mxcm0yNVJaYmdBPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkxNTA0MDAwCWZpbGU6b25saW5lLXVwZGF0ZS1maXh0dXJlLmV4ZQoxYWxWSVlaWHdYcHlib01YcjdRS1V3RlR4d3FvVFhqMERJWlBGVGR1Z1hyYnZKTFByR1RwaFJjTnRWRGM4d2tLUEsxV01Lcmk5dy9sSzMyVEg0dWRBZz09Cg==",
|
||||
"payloadBase64": "TVoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAFBFAABkhgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ29kZXgtWCBzeW50aGV0aWMgdXBkYXRlciBmaXh0dXJlOiBubyBzZWN0aW9ucywgbm8gZW50cnkgcG9pbnQsIG5vIGV4ZWN1dGFibGUgY29kZS4gTmV2ZXIgaW5zdGFsbC4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"sha256": "7764dacacade2426d04ec977eb28b25ed31f8e272c096c1a9d1e9873d0e49be4"
|
||||
}
|
||||
@@ -134,6 +134,7 @@ fn provider_test_connection() -> Connection {
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
upstream_api TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -160,6 +161,7 @@ fn provider_fixture(
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
toml_config: toml_config.map(ToString::to_string),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
}
|
||||
@@ -551,6 +553,7 @@ fn ccswitch_row_reader_supports_legacy_schema_without_category() {
|
||||
name: "OpenAI Official".to_string(),
|
||||
settings_config: "{}".to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
assert!(is_official_ccswitch_row(&official));
|
||||
}
|
||||
@@ -961,6 +964,7 @@ wire_api = "responses"
|
||||
api_key: Some("sk-same".to_string()),
|
||||
toml_config: Some(toml.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
};
|
||||
@@ -1809,6 +1813,7 @@ enabled = true
|
||||
api_key: Some("sk-provider-a".to_string()),
|
||||
toml_config: Some(provider_a_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
},
|
||||
@@ -1820,6 +1825,7 @@ enabled = true
|
||||
api_key: Some("sk-provider-b".to_string()),
|
||||
toml_config: Some(provider_b_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
},
|
||||
@@ -2730,6 +2736,7 @@ requires_openai_auth = true
|
||||
api_key: None,
|
||||
toml_config: Some(provider_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
})
|
||||
@@ -3198,6 +3205,7 @@ requires_openai_auth = true
|
||||
api_key: Some("sk-duplicate-current".to_string()),
|
||||
toml_config: Some(custom_config.trim_end().to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
};
|
||||
@@ -3656,8 +3664,11 @@ experimental_bearer_token = "sk-from-config"
|
||||
name: "Proxy".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
let provider = build_ccswitch_codex_provider(&row, &HashMap::new()).expect("provider");
|
||||
let provider = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.expect("valid provider")
|
||||
.expect("provider");
|
||||
assert_eq!(provider.id, "openai-custom");
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-from-config"));
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
@@ -3682,6 +3693,7 @@ requires_openai_auth = true
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
let magic_row = CcSwitchCodexRow {
|
||||
id: "magicai-1782956845071".to_string(),
|
||||
@@ -3706,6 +3718,7 @@ requires_openai_auth = true
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let mut sections = HashMap::new();
|
||||
@@ -3718,8 +3731,12 @@ requires_openai_auth = true
|
||||
}
|
||||
}
|
||||
|
||||
let sky = build_ccswitch_codex_provider(&sky_row, §ions).expect("sky");
|
||||
let magic = build_ccswitch_codex_provider(&magic_row, §ions).expect("magic");
|
||||
let sky = build_ccswitch_codex_provider(&sky_row, §ions)
|
||||
.expect("valid sky provider")
|
||||
.expect("sky");
|
||||
let magic = build_ccswitch_codex_provider(&magic_row, §ions)
|
||||
.expect("valid magic provider")
|
||||
.expect("magic");
|
||||
|
||||
assert_eq!(sky.provider_name, "Sky2api");
|
||||
assert_eq!(sky.base_url, "https://ikuncode.site/v1");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.26",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import { check as checkForTauriUpdate } from "@tauri-apps/plugin-updater";
|
||||
import { Update } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { AppUpdaterController, type AppUpdateEvent } from "./appUpdaterController";
|
||||
|
||||
@@ -8,7 +8,16 @@ export { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy } from "./appUpdaterControll
|
||||
export type { AppUpdaterPhase, AppUpdaterState, AppUpdaterFailure, AppUpdaterCheckResult, AppUpdaterCheckOptions } from "./appUpdaterController";
|
||||
|
||||
export const appUpdater = new AppUpdaterController({
|
||||
check: checkForTauriUpdate,
|
||||
check: async (options) => {
|
||||
const metadata = await invoke<ConstructorParameters<typeof Update>[0] | null>("check_online_app_update", {
|
||||
timeout: options.timeout,
|
||||
headers: options.headers ? Array.from(new Headers(options.headers).entries()) : undefined,
|
||||
proxy: options.proxy,
|
||||
target: options.target,
|
||||
allowDowngrades: options.allowDowngrades,
|
||||
});
|
||||
return metadata ? new Update(metadata) : null;
|
||||
},
|
||||
install: async (update, onEvent, options) => {
|
||||
const channel = new Channel<AppUpdateEvent>(onEvent);
|
||||
return invoke<{ restartRequired: boolean }>("install_app_update", {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CheckOptions, DownloadOptions } from "@tauri-apps/plugin-updater";
|
||||
|
||||
export type AppUpdaterPhase = "idle" | "checking" | "available" | "downloading" | "verifying" | "preparing" | "installing" | "handed-off" | "ready" | "error";
|
||||
export type AppUpdaterCheckFailureKind = "timeout" | "network" | "platform" | "invalid-release" | "public-key" | "unknown";
|
||||
export type AppUpdaterFailure = "check" | "download" | "verify" | "prepare" | "install" | "restart" | null;
|
||||
export type AppUpdaterState = Readonly<{
|
||||
phase: AppUpdaterPhase;
|
||||
@@ -12,6 +13,7 @@ export type AppUpdaterState = Readonly<{
|
||||
totalBytes: number | null;
|
||||
failure: AppUpdaterFailure;
|
||||
errorMessage: string | null;
|
||||
checkFailure?: AppUpdaterCheckFailureKind | null;
|
||||
logPath: string | null;
|
||||
takingLonger: boolean;
|
||||
}>;
|
||||
@@ -39,7 +41,7 @@ export type AppUpdaterDependencies = {
|
||||
export const UPDATER_SLOW_NOTICE_MS = 30_000;
|
||||
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
|
||||
phase: "idle", currentVersion: null, latestVersion: null, notes: null, publishedAt: null,
|
||||
downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false,
|
||||
downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, checkFailure: null, logPath: null, takingLonger: false,
|
||||
};
|
||||
export function isAppUpdateBusy(phase: AppUpdaterPhase): boolean {
|
||||
return ["downloading", "verifying", "preparing", "installing", "handed-off"].includes(phase);
|
||||
@@ -47,6 +49,45 @@ export function isAppUpdateBusy(phase: AppUpdaterPhase): boolean {
|
||||
function normalizeByteCount(value: number | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
// Plugin errors can contain private endpoints and credentials. Inspect bounded
|
||||
// text only to select one of these fixed explanations; never return that text.
|
||||
export function classifyAppUpdaterCheckError(error: unknown): AppUpdaterCheckFailureKind {
|
||||
let text = "";
|
||||
if (typeof error === "string") text = error;
|
||||
else if (error instanceof Error) text = `${error.name} ${error.message}`;
|
||||
else if (typeof error === "object" && error !== null) {
|
||||
const record = error as Record<string, unknown>;
|
||||
text = ["code", "kind", "type", "name", "message"].map((key) => record[key])
|
||||
.filter((value): value is string => typeof value === "string").join(" ");
|
||||
}
|
||||
text = text.slice(0, 4096).replace(/https?:\/\/[^\s<>"']+/gi, "").toLowerCase();
|
||||
if (/timeout|timed?\s*out|time out|etimedout|deadline exceeded|超时/.test(text)) return "timeout";
|
||||
if (/(?:platform|target)[\s\S]{0,200}(?:not found|missing|unsupported)|unsupported[\s\S]{0,80}(?:architecture|platform|operating system|\bos\b)|unsupportedarch|unsupportedos|targets?notfound|不支持.*(?:平台|系统|架构)/.test(text)) return "platform";
|
||||
if (/release json|(?:invalid|malformed)[^\n]{0,80}(?:release|json|manifest|version)|serialization|semver|invalid updater resource|missing field|expected (?:value|ident)[^\n]{0,80}line|eof while parsing|trailing characters|key must be a string|(?:relative url without a base)|更新信息.*(?:无效|格式)/.test(text)) return "invalid-release";
|
||||
if (/public.?key|pubkey|minisign|signature|invalid base64|base64[^\n]{0,80}(?:invalid|decode)|公钥|签名/.test(text)) return "public-key";
|
||||
if (/network|failed to fetch|error sending request|connection (?:refused|reset|closed)|connect error|dns|tcp|tls|certificate|resolve (?:host|dns)|http (?:error|status)|status code|could not connect|enotfound|econnreset|econnrefused|网络|连接失败/.test(text)) return "network";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function appUpdaterCheckFailureMessage(kind: AppUpdaterCheckFailureKind, lang: "zh" | "en" = "zh"): string {
|
||||
const messages = lang === "zh" ? {
|
||||
timeout: "在线更新检查超时。请检查网络或代理后重试在线检查。",
|
||||
network: "无法连接在线更新服务。请检查网络或代理后重试在线检查。",
|
||||
platform: "更新服务没有提供适用于当前系统或架构的在线安装包。请重试在线检查;也可使用下载页的对应平台安装包。",
|
||||
"invalid-release": "在线更新信息格式无效或未能读取。请重试在线检查;若持续失败,可使用下载页的安装包。",
|
||||
"public-key": "在线更新签名或公钥配置无效。请重试在线检查;若持续失败,请从下载页获取官方安装包。",
|
||||
unknown: "在线更新检查未完成。请重试在线检查;下载页可作为备用。",
|
||||
} : {
|
||||
timeout: "The online update check timed out. Check your network or proxy, then retry the online check.",
|
||||
network: "The online update service could not be reached. Check your network or proxy, then retry the online check.",
|
||||
platform: "The update service did not provide an online installer for this system or architecture. Retry the online check, or use your platform’s installer from the download page.",
|
||||
"invalid-release": "Online update information is invalid or could not be read. Retry the online check; if it keeps failing, use the installer from the download page.",
|
||||
"public-key": "The online update signature or public key configuration is invalid. Retry the online check; if it keeps failing, get the official installer from the download page.",
|
||||
unknown: "The online update check did not finish. Retry the online check; the download page is available as a fallback.",
|
||||
};
|
||||
return messages[kind] ?? messages.unknown;
|
||||
}
|
||||
|
||||
function installationError(error: unknown, fallback: AppUpdaterFailure) {
|
||||
// Only the app's structured installation command supplies user-facing details.
|
||||
// Do not render arbitrary plugin/network errors, which can include private URLs.
|
||||
@@ -97,7 +138,7 @@ export class AppUpdaterController {
|
||||
if (this.state.phase === "ready" || this.state.phase === "handed-off") return Promise.resolve(this.state.phase);
|
||||
if (!this.update) {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
this.setState({ phase: "error", failure: "check", checkFailure: "invalid-release", errorMessage: appUpdaterCheckFailureMessage("invalid-release"), latestVersion: null, notes: null, publishedAt: null });
|
||||
return Promise.resolve("error");
|
||||
}
|
||||
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => { this.updatePromise = null; });
|
||||
@@ -117,10 +158,13 @@ export class AppUpdaterController {
|
||||
|
||||
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "checking", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false });
|
||||
this.setState({ phase: "checking", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, checkFailure: null, latestVersion: null, notes: null, publishedAt: null, logPath: null, takingLonger: false });
|
||||
try {
|
||||
const nextUpdate = await this.dependencies.check(options);
|
||||
await this.replaceUpdate(nextUpdate);
|
||||
if (nextUpdate && (!Number.isInteger(nextUpdate.rid) || nextUpdate.rid < 0 || nextUpdate.rid > 0xffff_ffff)) {
|
||||
throw new Error("invalid updater resource");
|
||||
}
|
||||
if (!nextUpdate) {
|
||||
this.setState({ phase: "idle", latestVersion: null, notes: null, publishedAt: null });
|
||||
return "up-to-date";
|
||||
@@ -129,9 +173,10 @@ export class AppUpdaterController {
|
||||
this.setState({ phase: "available", currentVersion: nextUpdate.currentVersion, latestVersion: nextUpdate.version,
|
||||
notes: nextUpdate.body?.trim() || null, publishedAt: nextUpdate.date || null });
|
||||
return "available";
|
||||
} catch {
|
||||
} catch (error) {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
const checkFailure = classifyAppUpdaterCheckError(error);
|
||||
this.setState({ phase: "error", failure: "check", checkFailure, errorMessage: appUpdaterCheckFailureMessage(checkFailure), latestVersion: null, notes: null, publishedAt: null });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
@@ -141,7 +186,7 @@ export class AppUpdaterController {
|
||||
const attempt = ++this.attempt;
|
||||
let acceptingEvents = true;
|
||||
let handedOff = false;
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false });
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, checkFailure: null, logPath: null, takingLonger: false });
|
||||
this.armSlowNotice();
|
||||
try {
|
||||
const result = await this.dependencies.install(update, (event) => {
|
||||
@@ -194,7 +239,7 @@ export class AppUpdaterController {
|
||||
}
|
||||
private async performRestart(): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "restart";
|
||||
this.setState({ failure: null, errorMessage: null, logPath: null });
|
||||
this.setState({ failure: null, errorMessage: null, checkFailure: null, logPath: null });
|
||||
try { await this.dependencies.restart(); this.setState({ phase: "ready" }); return "ready"; }
|
||||
catch { this.setState({ phase: "error", failure: "restart" }); return "error"; }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy, type AppUpdaterState } from "../appUpdater";
|
||||
import { appUpdaterCheckFailureMessage } from "../appUpdaterController";
|
||||
import type { Lang, StartupDiagnostics } from "../types";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
|
||||
@@ -103,6 +104,10 @@ export function UpdateDialog({
|
||||
};
|
||||
const phase = updaterState.phase;
|
||||
const isBusy = isAppUpdateBusy(phase);
|
||||
const checkFailed = phase === "error" && updaterState.failure === "check";
|
||||
const failureDetails = checkFailed
|
||||
? appUpdaterCheckFailureMessage(updaterState.checkFailure ?? "unknown", lang)
|
||||
: updaterState.errorMessage;
|
||||
const totalBytes = updaterState.totalBytes;
|
||||
const hasKnownProgress = totalBytes !== null && totalBytes > 0;
|
||||
const progress = totalBytes !== null && totalBytes > 0
|
||||
@@ -129,8 +134,8 @@ export function UpdateDialog({
|
||||
handedOffDescription: "Codex-X 即将退出,请在安装窗口继续。首次升级旧版时,系统可能需要一次管理员授权。",
|
||||
readyTitle: "更新已准备好",
|
||||
readyDescription: "重新启动 Codex-X 即可使用新版本。",
|
||||
errorTitle: "更新没有完成",
|
||||
errorDescription: updaterState.failure === "restart"
|
||||
errorTitle: checkFailed ? "在线更新检查失败" : "更新没有完成",
|
||||
errorDescription: checkFailed ? "在线安装尚未开始。请重试在线检查;下载页可作为备用。" : updaterState.failure === "restart"
|
||||
? "软件未能重新启动,请再试一次。"
|
||||
: "请重试;如果仍然失败,也可以前往下载页更新。",
|
||||
idleTitle: "当前已是最新版本",
|
||||
@@ -152,8 +157,9 @@ export function UpdateDialog({
|
||||
detailLabel: "失败原因",
|
||||
logLabel: "安装日志",
|
||||
restart: "重新启动",
|
||||
retry: "重试",
|
||||
retry: checkFailed ? "重试在线检查" : "重试",
|
||||
downloadPage: "打开下载页",
|
||||
backupDownloadPage: "打开下载页(备用)",
|
||||
releaseNotes: "本次更新",
|
||||
}
|
||||
: {
|
||||
@@ -175,8 +181,8 @@ export function UpdateDialog({
|
||||
handedOffDescription: "Codex-X will exit. Continue in the installer window. Upgrading an older installation may ask for administrator permission once.",
|
||||
readyTitle: "Update is ready",
|
||||
readyDescription: "Restart Codex-X to use the new version.",
|
||||
errorTitle: "Update did not finish",
|
||||
errorDescription: updaterState.failure === "restart"
|
||||
errorTitle: checkFailed ? "Online update check failed" : "Update did not finish",
|
||||
errorDescription: checkFailed ? "Online installation has not started. Retry the online check; the download page is available as a fallback." : updaterState.failure === "restart"
|
||||
? "Codex-X could not restart. Please try again."
|
||||
: "Try again, or use the download page if the problem continues.",
|
||||
idleTitle: "Codex-X is up to date",
|
||||
@@ -198,8 +204,9 @@ export function UpdateDialog({
|
||||
detailLabel: "What went wrong",
|
||||
logLabel: "Installation log",
|
||||
restart: "Restart",
|
||||
retry: "Try again",
|
||||
retry: checkFailed ? "Retry online check" : "Try again",
|
||||
downloadPage: "Open download page",
|
||||
backupDownloadPage: "Open download page (fallback)",
|
||||
releaseNotes: "What's new",
|
||||
};
|
||||
|
||||
@@ -250,7 +257,7 @@ export function UpdateDialog({
|
||||
? (
|
||||
<>
|
||||
<Button variant="secondary" icon={<Download size={16} />} onClick={onDownload}>
|
||||
{copy.downloadPage}
|
||||
{checkFailed ? copy.backupDownloadPage : copy.downloadPage}
|
||||
</Button>
|
||||
<Button icon={<RotateCcw size={16} />} onClick={() => void onRetry?.()}>
|
||||
{copy.retry}
|
||||
@@ -328,10 +335,10 @@ export function UpdateDialog({
|
||||
<p>{copy.slowDescription}</p>
|
||||
</section>
|
||||
)}
|
||||
{phase === "error" && updaterState.errorMessage && (
|
||||
{phase === "error" && failureDetails && (
|
||||
<section className="cx-update-notes" role="alert">
|
||||
<strong>{copy.detailLabel}</strong>
|
||||
<p>{updaterState.errorMessage}</p>
|
||||
<p>{failureDetails}</p>
|
||||
{updaterState.logPath && <p className="cx-update-log-path">{copy.logLabel}: {updaterState.logPath}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useId } from "react";
|
||||
import { AlertTriangle, CheckCircle2, FileSearch, FolderOpen, Loader2, RefreshCw, Wrench } from "lucide-react";
|
||||
import type { ConfigHealthReport } from "../configHealthTypes";
|
||||
import { configHealthIssueLocation, configHealthRepairUnavailableReason } from "../configHealthPresentation";
|
||||
import type { ConfigHealthIssue, ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
|
||||
@@ -15,6 +16,15 @@ export type ConfigHealthPanelProps = {
|
||||
onOpenConfig?: () => void;
|
||||
};
|
||||
|
||||
export function ConfigHealthIssueDetails({ issue, report, lang }: { issue: ConfigHealthIssue; report: ConfigHealthReport; lang: "zh" | "en" }) {
|
||||
const location = configHealthIssueLocation(issue, report, lang);
|
||||
return <>
|
||||
<p className="cx-config-health-location"><code>{location.path}</code><span>{location.position}</span></p>
|
||||
<p className="cx-config-health-key">{lang === "zh" ? "参数:" : "Setting: "}<code>{location.key}</code></p>
|
||||
{issue.suggestion && <p className="cx-config-health-suggestion">{lang === "zh" ? "修复建议:" : "Suggested fix: "}{issue.suggestion}</p>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ConfigHealthStatus({ lang, report, checking, repairing, error }: Pick<ConfigHealthPanelProps, "lang" | "report" | "checking" | "repairing" | "error">) {
|
||||
const zh = lang === "zh";
|
||||
const busy = checking || repairing;
|
||||
@@ -60,14 +70,14 @@ export function ConfigHealthPanel({ lang, report, checking, repairing, error, on
|
||||
<ul className="cx-config-health-issues">
|
||||
{report.issues.map((issue, index) => <li key={`${issue.code}-${index}`}>
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
<div><strong>{issue.title}</strong><p>{issue.description}</p></div>
|
||||
<div><strong>{issue.title}</strong><p>{issue.description}</p><ConfigHealthIssueDetails issue={issue} report={report} lang={lang} /></div>
|
||||
</li>)}
|
||||
</ul>
|
||||
{report.canRepair && report.repairSummary.length > 0 && <div className="cx-config-health-plan">
|
||||
<strong>{zh ? "点击修复后,将进行以下调整:" : "Repair will make these changes:"}</strong>
|
||||
<ul>{report.repairSummary.map((summary, index) => <li key={index}>{summary}</li>)}</ul>
|
||||
</div>}
|
||||
{manualIssues && <p className="cx-config-health-note">{zh ? "部分问题需要手动检查。为保留你的配置,软件不会猜测或覆盖无法确认的内容。" : "Some issues need a manual check. Settings that cannot be determined safely will be left for you to review."}</p>}
|
||||
{manualIssues && <p className="cx-config-health-note">{zh ? "请按每项问题的文件位置与修复建议处理需要手动确认的参数,然后重新检查。" : "Use each issue’s file location and suggested fix to review settings that need manual confirmation, then check again."}</p>}
|
||||
</div>}
|
||||
|
||||
{report?.status === "missing" && <p className="cx-config-health-note">{zh ? "还没有找到配置文件。完成 Codex 登录或启用供应商后,可以再次检查。" : "No configuration file was found. Check again after signing in to Codex or enabling a provider."}</p>}
|
||||
@@ -80,6 +90,7 @@ export function ConfigHealthPanel({ lang, report, checking, repairing, error, on
|
||||
<div className="cx-config-health-actions">
|
||||
{onOpenConfig && (hasIssues || report?.status === "unavailable") && <Button variant="ghost" size="sm" icon={<FolderOpen size={15} />} disabled={busy} onClick={onOpenConfig}>{zh ? "查看配置" : "View configuration"}</Button>}
|
||||
<Button variant="secondary" size="sm" icon={checking ? <Loader2 size={15} className="cx-config-health-spinner" /> : <RefreshCw size={15} />} disabled={busy} onClick={onCheck}>{checking ? (zh ? "检查中" : "Checking") : (zh ? "检查配置" : "Check configuration")}</Button>
|
||||
{hasIssues && !report.canRepair && <Button variant="secondary" size="sm" icon={<Wrench size={15} />} disabled title={configHealthRepairUnavailableReason(report, lang)}>{zh ? "需手动修复" : "Manual repair needed"}</Button>}
|
||||
{report?.canRepair && hasIssues && <Button size="sm" icon={repairing ? <Loader2 size={15} className="cx-config-health-spinner" /> : <Wrench size={15} />} disabled={busy} onClick={onRepair}>{repairing ? (zh ? "修复中" : "Repairing") : (zh ? "修复配置" : "Repair configuration")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AlertTriangle, ArrowRight, Loader2, Wrench, X } from "lucide-react";
|
||||
import { primaryConfigHealthIssue } from "../configHealthPresentation";
|
||||
import { ConfigHealthIssueDetails } from "./ConfigHealthPanel";
|
||||
import type { ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
@@ -15,12 +17,14 @@ export type ConfigHealthToastProps = {
|
||||
/** The caller owns notification lifetime; CSS never dismisses this toast. */
|
||||
export function ConfigHealthToast({ lang, report, repairing, onRepair, onDismiss, onOpenSettings }: ConfigHealthToastProps) {
|
||||
const zh = lang === "zh";
|
||||
const issue = primaryConfigHealthIssue(report);
|
||||
return <aside className="cx-config-health-toast" aria-label={zh ? "配置检查提醒" : "Configuration check notification"}>
|
||||
<span className="cx-config-health-toast-icon" aria-hidden="true"><AlertTriangle size={20} /></span>
|
||||
<div className="cx-config-health-toast-content">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<strong>{repairing ? (zh ? "正在修复 Codex 配置" : "Repairing Codex configuration") : (zh ? "检测到 Codex 配置问题" : "Codex configuration needs attention")}</strong>
|
||||
<p>{(report.canRepair ? report.issues[0]?.title : report.issues[0]?.description) || (zh ? "部分配置可能影响 Codex 正常使用。" : "Some settings may prevent Codex from working correctly.")}</p>
|
||||
<p>{(report.canRepair ? issue?.title : issue?.description) || (zh ? "部分配置可能影响 Codex 正常使用。" : "Some settings may prevent Codex from working correctly.")}</p>
|
||||
{issue && <ConfigHealthIssueDetails issue={issue} report={report} lang={lang} />}
|
||||
{report.canRepair && report.repairSummary[0] && <p className="cx-config-health-toast-plan">{report.repairSummary[0]}</p>}
|
||||
</div>
|
||||
<div className="cx-config-health-toast-actions">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ProviderHeadersEditor } from "./ProviderHeadersEditor";
|
||||
import { normalizeProviderHeaders, validateProviderHeaders } from "../providerHeaders";
|
||||
import type { ProviderHeader } from "../providerHeaders";
|
||||
import { createProviderHeadersControl } from "../providerHeadersControl";
|
||||
import type { ProviderHeadersControlState } from "../providerHeadersControl";
|
||||
|
||||
export function ProviderHeadersControl({ lang, configText, disabled = false, onConfigChange, onBusyChange, onValidityChange }: {
|
||||
lang: "zh" | "en";
|
||||
configText: string;
|
||||
disabled?: boolean;
|
||||
onConfigChange: (configText: string) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onValidityChange: (valid: boolean) => void;
|
||||
}) {
|
||||
const runtime = useRef({ configText, onConfigChange, onBusyChange, onValidityChange });
|
||||
runtime.current = { configText, onConfigChange, onBusyChange, onValidityChange };
|
||||
const control = useRef<ReturnType<typeof createProviderHeadersControl> | null>(null);
|
||||
const [rows, setRows] = useState<ProviderHeader[]>([]);
|
||||
const [state, setState] = useState<ProviderHeadersControlState>({ busy: true, reading: true, valid: false, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
const instance = createProviderHeadersControl({
|
||||
read: (text) => invoke<ProviderHeader[]>("read_provider_headers", { configText: text }),
|
||||
update: (text, headers) => invoke<string>("update_provider_headers", { configText: text, headers }),
|
||||
getConfigText: () => runtime.current.configText,
|
||||
isValid: (next) => validateProviderHeaders(next).valid,
|
||||
normalize: normalizeProviderHeaders,
|
||||
onRowsChange: setRows,
|
||||
onConfigChange: (text) => runtime.current.onConfigChange(text),
|
||||
onStateChange: (next) => {
|
||||
setState(next);
|
||||
runtime.current.onBusyChange(next.busy);
|
||||
runtime.current.onValidityChange(next.valid);
|
||||
},
|
||||
});
|
||||
control.current = instance;
|
||||
void instance.load(runtime.current.configText);
|
||||
return () => {
|
||||
instance.dispose();
|
||||
if (control.current === instance) control.current = null;
|
||||
runtime.current.onBusyChange(false);
|
||||
runtime.current.onValidityChange(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void control.current?.load(configText); }, [configText]);
|
||||
|
||||
const errorText = state.error === "read"
|
||||
? lang === "zh" ? "无法读取 Headers,请检查供应商 TOML 后重试。" : "Could not read headers. Check the provider TOML and retry."
|
||||
: lang === "zh" ? "无法更新 Headers,请重试或检查供应商 TOML。" : "Could not update headers. Retry or check the provider TOML.";
|
||||
|
||||
return <div className="cx-provider-headers-control">
|
||||
<ProviderHeadersEditor lang={lang} rows={rows} disabled={disabled || state.reading} onChange={(next) => { void control.current?.changeRows(next); }} />
|
||||
{state.busy && <p className="cx-provider-headers-hint" role="status">{state.reading
|
||||
? lang === "zh" ? "正在读取 Headers…" : "Reading headers…"
|
||||
: lang === "zh" ? "正在更新配置…" : "Updating configuration…"}</p>}
|
||||
{state.error && <div className="cx-provider-headers-feedback"><p className="cx-provider-headers-error" role="status">{errorText}</p><button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" disabled={disabled || state.busy} onClick={() => { void control.current?.retry(); }}>{lang === "zh" ? "重试" : "Retry"}</button></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useId } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
createProviderHeader,
|
||||
removeProviderHeader,
|
||||
updateProviderHeader,
|
||||
validateProviderHeaders,
|
||||
} from "../providerHeaders";
|
||||
import type { ProviderHeader, ProviderHeaderSource } from "../providerHeaders";
|
||||
import { applyProviderHeaderPreset, PROVIDER_HEADER_PRESETS, providerHeaderValuePlaceholder } from "../providerHeaderPresets";
|
||||
import "../styles/provider-headers.css";
|
||||
|
||||
export function ProviderHeadersEditor({ lang, rows, disabled = false, onChange }: {
|
||||
lang: "zh" | "en";
|
||||
rows: readonly ProviderHeader[];
|
||||
disabled?: boolean;
|
||||
onChange: (rows: ProviderHeader[]) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const copy = lang === "zh" ? {
|
||||
title: "自定义请求头 Headers", optional: "可选", hint: "按供应商要求设置 User-Agent(客户端标识)或其他附加请求头;不填写时使用默认设置。",
|
||||
presets: "User-Agent 预设", presetHint: "预设只设置 User-Agent,其他请求头保留;已有 User-Agent 会更新为所选固定值。预设是否适用以供应商要求为准。",
|
||||
envHint: "环境变量模式只保存变量名;直连由 Codex 读取,路由由 Codex-X 读取,请在启动对应应用前设置。",
|
||||
add: "添加 Header", empty: "尚未配置自定义 Headers", name: "名称", value: "值", source: "值来源", literal: "固定值", env: "环境变量", remove: "删除 Header",
|
||||
placeholder: "例如 User-Agent", envPlaceholder: "例如 PROVIDER_HEADER_VALUE", fix: "请先修正标红的 Headers,再保存供应商。",
|
||||
row: (index: number) => `第 ${index + 1} 行`,
|
||||
} : {
|
||||
title: "Custom headers", optional: "Optional", hint: "Set User-Agent (client identification) or other HTTP headers required by your provider. Leave them empty to keep the defaults.",
|
||||
presets: "User-Agent presets", presetHint: "Presets set only User-Agent and keep other headers. An existing User-Agent becomes the selected literal value. Check your provider requirements before choosing one.",
|
||||
envHint: "Environment mode saves only the variable name. Set it before launching Codex for direct requests, or Codex-X for routed requests.",
|
||||
add: "Add header", empty: "No custom headers configured", name: "Name", value: "Value", source: "Value source", literal: "Literal value", env: "Environment variable", remove: "Remove header",
|
||||
placeholder: "e.g. User-Agent", envPlaceholder: "e.g. PROVIDER_HEADER_VALUE", fix: "Correct the highlighted headers before saving this provider.",
|
||||
row: (index: number) => `Row ${index + 1}`,
|
||||
};
|
||||
const validation = validateProviderHeaders(rows, lang);
|
||||
const update = (index: number, patch: Partial<ProviderHeader>) => {
|
||||
if (!disabled) onChange(updateProviderHeader(rows, index, patch));
|
||||
};
|
||||
|
||||
return <section className="cx-provider-headers" aria-labelledby={`${id}-title`}>
|
||||
<div className="cx-provider-headers-heading">
|
||||
<div><strong id={`${id}-title`}>{copy.title}<span>{copy.optional}</span></strong><p>{copy.hint}</p></div>
|
||||
<div className="cx-provider-headers-actions">
|
||||
<select className="cx-provider-headers-presets" aria-label={copy.presets} aria-describedby={`${id}-preset-hint`} value="" disabled={disabled} onChange={(event) => {
|
||||
const selected = event.currentTarget.value;
|
||||
if (selected) onChange(applyProviderHeaderPreset(rows, selected));
|
||||
}}>
|
||||
<option value="">{copy.presets}</option>
|
||||
{PROVIDER_HEADER_PRESETS.map((preset) => <option key={preset.id} value={preset.id}>{preset.label[lang]} · {preset.value}</option>)}
|
||||
</select>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" disabled={disabled} onClick={() => onChange([...rows, createProviderHeader()])}><Plus size={14} aria-hidden="true" />{copy.add}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="cx-provider-headers-hint cx-provider-headers-preset-hint" id={`${id}-preset-hint`}>{copy.presetHint}</p>
|
||||
{rows.length === 0 ? <p className="cx-provider-headers-empty">{copy.empty}</p> : <div className="cx-provider-headers-rows">
|
||||
{rows.map((row, index) => {
|
||||
const error = validation.errors[index];
|
||||
return <div key={index} className="cx-provider-header-row" role="group" aria-label={copy.row(index)}>
|
||||
<label className="cx-provider-header-field"><span>{copy.name}</span><input value={row.name} placeholder={copy.placeholder} aria-label={`${copy.name} · ${copy.row(index)}`} aria-invalid={Boolean(error.name)} aria-describedby={error.name ? `${id}-${index}-name-error` : undefined} onChange={(event) => update(index, { name: event.currentTarget.value })} disabled={disabled} autoComplete="off" autoCapitalize="none" spellCheck={false} />{error.name && <small id={`${id}-${index}-name-error`}>{error.name}</small>}</label>
|
||||
<label className="cx-provider-header-field"><span>{copy.source}</span><select value={row.source} aria-label={`${copy.source} · ${copy.row(index)}`} aria-invalid={Boolean(error.source)} aria-describedby={error.source ? `${id}-${index}-source-error` : undefined} onChange={(event) => update(index, { source: event.currentTarget.value as ProviderHeaderSource })} disabled={disabled}><option value="static">{copy.literal}</option><option value="env">{copy.env}</option></select>{error.source && <small id={`${id}-${index}-source-error`}>{error.source}</small>}</label>
|
||||
<label className="cx-provider-header-field"><span>{row.source === "env" ? copy.env : copy.value}</span><input value={row.value} placeholder={row.source === "env" ? copy.envPlaceholder : providerHeaderValuePlaceholder(row.name, lang)} aria-label={`${row.source === "env" ? copy.env : copy.value} · ${copy.row(index)}`} aria-invalid={Boolean(error.value)} aria-describedby={error.value ? `${id}-${index}-value-error` : row.source === "env" ? `${id}-env-hint` : undefined} onChange={(event) => update(index, { value: event.currentTarget.value })} disabled={disabled} autoComplete="off" autoCapitalize="none" spellCheck={false} />{error.value && <small id={`${id}-${index}-value-error`}>{error.value}</small>}</label>
|
||||
<button type="button" className="cx-providers-icon-button cx-providers-icon-button--danger cx-provider-header-remove" title={`${copy.remove} · ${copy.row(index)}`} aria-label={`${copy.remove} · ${copy.row(index)}`} disabled={disabled} onClick={() => onChange(removeProviderHeader(rows, index))}><Trash2 size={14} aria-hidden="true" /></button>
|
||||
</div>;
|
||||
})}
|
||||
</div>}
|
||||
<p className="cx-provider-headers-hint" id={`${id}-env-hint`}>{copy.envHint}</p>
|
||||
{rows.some((row) => row.name.trim().toLowerCase() === "authorization") && <p className="cx-provider-headers-hint">{lang === "zh" ? "直连使用 Authorization Header 认证时,请留空 API Key;填写 API Key 会替换原有 Authorization。" : "For direct Authorization header authentication, leave API Key empty. An explicit API Key replaces the existing Authorization header."}</p>}
|
||||
{!validation.valid && <p className="cx-provider-headers-error" role="status">{copy.fix}</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ function getCopy(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "模型映射", optional: "可选", subtitle: "让 Codex 的模型菜单显示这个供应商实际提供的模型。",
|
||||
hint: "适用于支持 Responses 接口的模型。保存并启用后,重启 Codex 更新模型菜单。已有会话会保留原来的模型选择。",
|
||||
capabilities: "模型菜单允许文本与图片输入;实际能力由供应商接口决定。Ultra 还需在 Codex 设置中开启「模型选择器滑块中的 Ultra 选项」。",
|
||||
displayName: "菜单显示名称", displayPlaceholder: "留空使用模型 ID", model: "实际模型 ID", modelPlaceholder: "填写供应商提供的模型 ID",
|
||||
context: "上下文窗口", contextPlaceholder: "可选,如 128000", contextHint: "单位为 Token;留空使用默认值,最大 10,000,000。",
|
||||
addCurrent: "添加当前模型", importModels: "导入已获取模型", addRow: "添加空行", remove: "删除映射",
|
||||
@@ -25,6 +26,7 @@ function getCopy(lang: Language) {
|
||||
} : {
|
||||
title: "Model mappings", optional: "Optional", subtitle: "Show this provider’s actual models in the Codex model menu.",
|
||||
hint: "For models that support the Responses API. Save and enable this provider, then restart Codex to update its model menu. Existing conversations keep their selected model.",
|
||||
capabilities: "The menu allows text and image input; actual support depends on the provider API. To show Ultra, also enable the Ultra option in the model picker slider in Codex settings.",
|
||||
displayName: "Menu display name", displayPlaceholder: "Defaults to the model ID", model: "Actual model ID", modelPlaceholder: "Enter the provider’s model ID",
|
||||
context: "Context window", contextPlaceholder: "Optional, e.g. 128000", contextHint: "In tokens. Leave blank for the default; maximum 10,000,000.",
|
||||
addCurrent: "Add current model", importModels: "Import fetched models", addRow: "Add row", remove: "Remove mapping",
|
||||
@@ -136,6 +138,7 @@ export function ProviderModelMappings({ lang, rows, currentModel, availableModel
|
||||
<summary><ListFilter size={17} aria-hidden="true" /><div><strong>{copy.title}<span>{copy.optional}</span></strong><p>{copy.subtitle}</p></div><span className={`cx-provider-mappings-count${!validation.valid ? " cx-provider-mappings-count--invalid" : ""}`} title={copy.limit}>{validation.size} / {MAX_MAPPINGS}</span><ChevronDown size={16} className="cx-provider-mappings-chevron" aria-hidden="true" /></summary>
|
||||
<div className="cx-provider-mappings-body">
|
||||
<p className="cx-provider-mappings-hint">{copy.hint}</p>
|
||||
<p className="cx-provider-mappings-hint">{copy.capabilities}</p>
|
||||
<div className="cx-provider-mappings-toolbar">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add(current)} disabled={disabled || !canAddCurrent} title={currentIncluded ? copy.currentExists : !current ? copy.noCurrent : hasInvalidModelId(current) ? copy.invalidDefault : !canAddCurrent ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addCurrent}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={importModels} disabled={disabled || !canImport} title={!availableModels.length ? copy.fetchFirst : !candidates.length ? copy.allImported : !canImport ? copy.limit : undefined}><Download size={14} aria-hidden="true" />{copy.importModels}</button>
|
||||
|
||||
@@ -95,7 +95,7 @@ function labelHash(value: string) {
|
||||
return `${(first >>> 0).toString(16)}-${(second >>> 0).toString(16)}`;
|
||||
}
|
||||
export function configHealthIssueKey(report: ConfigHealthReport) {
|
||||
return labelHash(JSON.stringify(report.issues.map(({ code, title, description }) => [code, title, description]).sort()));
|
||||
return labelHash(JSON.stringify(report.issues.map(({ code, title, description, key, suggestion, repairable }) => [code, title, description, key, suggestion, repairable]).sort()));
|
||||
}
|
||||
type StorageLike = Pick<Storage, "getItem" | "setItem">;
|
||||
export function createConfigHealthNoticeRegistry(storage?: StorageLike) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ConfigHealthIssue, ConfigHealthReport } from "./configHealthTypes";
|
||||
|
||||
// Fall back for older backend/fixture reports while retaining exact locations
|
||||
// from the diagnostic command. Missing definitions do not have a source line.
|
||||
export function configHealthIssueLocation(issue: ConfigHealthIssue, report: ConfigHealthReport, lang: "zh" | "en") {
|
||||
const zh = lang === "zh";
|
||||
const path = issue.path || `${report.codexDir.replace(/[\\/]$/, "")}/config.toml`;
|
||||
const position = issue.line != null && issue.line > 0
|
||||
? (zh ? `第 ${issue.line} 行${issue.column != null ? `,第 ${issue.column} 列` : ""}`
|
||||
: `line ${issue.line}${issue.column != null ? `, column ${issue.column}` : ""}`)
|
||||
: !issue.key
|
||||
? (zh ? "旧版报告未提供行列,请重新检查" : "location not supplied; check again")
|
||||
: issue.key.startsWith('"<')
|
||||
? (zh ? "文件层面" : "file level")
|
||||
: (zh ? "未定义,尚无对应行" : "not defined; no source line");
|
||||
return { path, position, key: issue.key || (zh ? "配置文件" : "configuration file") };
|
||||
}
|
||||
|
||||
export function primaryConfigHealthIssue(report: ConfigHealthReport) {
|
||||
return (report.canRepair ? report.issues.find((issue) => issue.repairable) : undefined) ?? report.issues[0];
|
||||
}
|
||||
|
||||
export function configHealthRepairUnavailableReason(report: ConfigHealthReport, lang: "zh" | "en") {
|
||||
return (report.issues.find((issue) => issue.code === "config-linked-file")
|
||||
?? report.issues.find((issue) => !issue.repairable))?.suggestion
|
||||
|| (lang === "zh" ? "现有配置无法可靠确定,请按问题中的建议修改后重新检查。" : "The correct settings cannot be determined safely. Follow the issue's suggestion and check again.");
|
||||
}
|
||||
@@ -3,6 +3,11 @@ export type ConfigHealthIssue = {
|
||||
title: string;
|
||||
description: string;
|
||||
repairable: boolean;
|
||||
path: string;
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
key: string;
|
||||
suggestion: string;
|
||||
};
|
||||
|
||||
export type ConfigHealthReport = {
|
||||
|
||||
+123
-41
@@ -1,3 +1,5 @@
|
||||
import { releaseInfoFromFallback, releaseInfoFromCheckFailure } from "./updateCheckFlow";
|
||||
import { providerUpstreamApi, providerRequiresConversion } from "./providerProtocol";
|
||||
import React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import ReactDOM from "react-dom/client";
|
||||
@@ -24,7 +26,7 @@ import { PageTransition } from "./components/PageTransition";
|
||||
import { cx } from "./components/ui";
|
||||
import { appUpdater, isAppUpdateBusy, useAppUpdater } from "./appUpdater";
|
||||
import { providerProfilesMatch, type ProviderProfile } from "./providerProfiles";
|
||||
import { orderProviderRows } from "./providerRowOrder";
|
||||
import { applyProviderRowOrder, orderProviderRows } from "./providerRowOrder";
|
||||
import { createPresetProvider, getProviderPreset, getProviderPresetVariant } from "./providerPresets";
|
||||
import { validateProviderModelMappings } from "./components/ProviderModelMappings";
|
||||
import { createOfficialProfileMonitor } from "./officialProfileMonitor";
|
||||
@@ -129,6 +131,7 @@ const defaultProviderForm: SavedProvider = {
|
||||
apiKey: "",
|
||||
tomlConfig: "",
|
||||
wireApi: "responses",
|
||||
upstreamApi: "responses",
|
||||
requiresOpenaiAuth: false,
|
||||
};
|
||||
|
||||
@@ -140,6 +143,7 @@ const blankProviderForm: SavedProvider = {
|
||||
apiKey: "",
|
||||
tomlConfig: "",
|
||||
wireApi: "responses",
|
||||
upstreamApi: "responses",
|
||||
requiresOpenaiAuth: false,
|
||||
};
|
||||
|
||||
@@ -568,7 +572,7 @@ function buildProviderTomlPreview(provider: SavedProvider) {
|
||||
const providerKey = "custom";
|
||||
const baseUrl = provider.baseUrl.trim().replace(/\/+$/, "");
|
||||
if (!model || !name || !baseUrl) return "";
|
||||
const wireApi = provider.wireApi || "responses";
|
||||
const wireApi = "responses";
|
||||
return [
|
||||
`model_provider = "${tomlEscape(providerKey)}"`,
|
||||
`model = "${tomlEscape(model)}"`,
|
||||
@@ -755,6 +759,7 @@ function App() {
|
||||
const [editingPromptId, setEditingPromptId] = React.useState<string | null>(null);
|
||||
const [editingBuiltinPrompt, setEditingBuiltinPrompt] = React.useState<BuiltinPromptDetail | null>(null);
|
||||
const [savedProviders, setSavedProviders] = React.useState<SavedProvider[]>([]);
|
||||
const [providerOrder, setProviderOrder] = React.useState<{ directory: string; order: string[] }>({ directory: "", order: [] });
|
||||
const [officialProfiles, setOfficialProfiles] = React.useState<OfficialProfileSummary[]>([]);
|
||||
const [editingOfficialProfileId, setEditingOfficialProfileId] = React.useState<string | null>(DEFAULT_OFFICIAL_PROFILE_ID);
|
||||
const [creatingProvider, setCreatingProvider] = React.useState(false);
|
||||
@@ -836,6 +841,7 @@ function App() {
|
||||
const officialMonitorReadyRef = React.useRef(false);
|
||||
officialMonitorReadyRef.current = Boolean(state) && !refreshing;
|
||||
const savedProvidersRequestRef = React.useRef(0);
|
||||
const providerOrderRequestRef = React.useRef(0);
|
||||
const loadingGenerationRef = React.useRef(0);
|
||||
const loadingTokensRef = React.useRef(new Set<number>());
|
||||
const actionBusyGenerationRef = React.useRef(0);
|
||||
@@ -933,6 +939,18 @@ function App() {
|
||||
savedProvidersRequestRef.current += 1;
|
||||
setSavedProviders(providers);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const directory = state?.codexDir || "";
|
||||
const request = ++providerOrderRequestRef.current;
|
||||
if (!directory) { setProviderOrder({ directory: "", order: [] }); return; }
|
||||
void invoke<string[]>("get_provider_order", { configDir: directory }).then((order) => {
|
||||
if (request === providerOrderRequestRef.current) setProviderOrder({ directory, order });
|
||||
}).catch((nextError) => {
|
||||
if (request !== providerOrderRequestRef.current) return;
|
||||
setProviderOrder({ directory, order: [] });
|
||||
setError(String(nextError));
|
||||
});
|
||||
}, [state?.codexDir]);
|
||||
const currentInstructionId = instructionIdFromPath(state?.instructionFile, instructionTemplates);
|
||||
const releaseStatusLabel = React.useMemo(() => {
|
||||
if (updater.state.phase === "downloading") return lang === "zh" ? "下载中" : "Downloading";
|
||||
@@ -1175,10 +1193,11 @@ function App() {
|
||||
const localRows = React.useMemo(() => {
|
||||
return savedProviders.map((p) => ({
|
||||
...p,
|
||||
model: effectiveActiveProviderId === p.id ? state?.model || p.model : p.model,
|
||||
source: "local" as const,
|
||||
isCurrent: effectiveActiveProviderId === p.id,
|
||||
}));
|
||||
}, [effectiveActiveProviderId, savedProviders]);
|
||||
}, [effectiveActiveProviderId, savedProviders, state?.model]);
|
||||
|
||||
const currentOfficialProfileId = state?.isOfficialProvider
|
||||
? state.activeOfficialProfileId || DEFAULT_OFFICIAL_PROFILE_ID
|
||||
@@ -1201,7 +1220,7 @@ function App() {
|
||||
source: "official",
|
||||
providerName: profile.providerName,
|
||||
baseUrl: "https://chatgpt.com/codex",
|
||||
model: profile.model || "official",
|
||||
model: profile.id === currentOfficialProfileId ? profile.model || state?.model || "" : profile.model || "",
|
||||
apiKey: "",
|
||||
wireApi: "official",
|
||||
requiresOpenaiAuth: true,
|
||||
@@ -1214,7 +1233,7 @@ function App() {
|
||||
});
|
||||
const rows = orderProviderRows(officialRow(defaultProfile), detectedRows, localRows);
|
||||
return [rows[0], ...officialProfiles.filter((profile) => !profile.isDefault).map(officialRow), ...rows.slice(1)];
|
||||
}, [currentOfficialProfileId, detectedRows, lang, localRows, officialProfiles, state?.officialAuthAvailable]);
|
||||
}, [currentOfficialProfileId, detectedRows, lang, localRows, officialProfiles, state?.officialAuthAvailable, state?.model]);
|
||||
|
||||
const findLocalProviderForRow = React.useCallback((row: ProviderRow) => {
|
||||
if (row.source === "official") return undefined;
|
||||
@@ -1241,7 +1260,8 @@ function App() {
|
||||
};
|
||||
}, [findLocalProviderForRow, state?.configText]);
|
||||
|
||||
const providerPageRows = React.useMemo<ProviderRow[]>(() => providerRows.map((row) => {
|
||||
const providerPageRows = React.useMemo<ProviderRow[]>(() => applyProviderRowOrder(providerRows,
|
||||
providerOrder.directory === state?.codexDir ? providerOrder.order : []).map((row) => {
|
||||
const local = findLocalProviderForRow(row);
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -1249,8 +1269,10 @@ function App() {
|
||||
providerName: row.providerName,
|
||||
baseUrl: row.baseUrl,
|
||||
model: row.model,
|
||||
modelDisplayName: local?.modelMappings?.find((mapping) => mapping.model === row.model)?.displayName,
|
||||
apiKey: row.apiKey,
|
||||
wireApi: row.wireApi,
|
||||
upstreamApi: local?.upstreamApi || row.upstreamApi,
|
||||
requiresOpenaiAuth: row.requiresOpenaiAuth,
|
||||
isCurrent: row.isCurrent,
|
||||
isDefaultOfficial: row.isDefaultOfficial,
|
||||
@@ -1266,7 +1288,32 @@ function App() {
|
||||
testable: row.source !== "official",
|
||||
testingKey: `${row.source}-${row.id}`,
|
||||
};
|
||||
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows]);
|
||||
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows, providerOrder, state?.codexDir]);
|
||||
|
||||
const saveProviderOrder = async (order: string[]): Promise<boolean> => {
|
||||
const directory = state?.codexDir;
|
||||
if (!directory || providerOrder.directory !== directory || loadingTokensRef.current.size || actionBusyTokensRef.current.size) return false;
|
||||
const previous = providerOrder;
|
||||
const request = ++providerOrderRequestRef.current;
|
||||
const actionToken = beginActionBusy("reorderProviders");
|
||||
setProviderOrder({ directory, order });
|
||||
try {
|
||||
const saved = await invoke<string[]>("save_provider_order", { configDir: directory, order });
|
||||
if (request === providerOrderRequestRef.current && normalizedConfigDirForComparison(directory) === activeConfigDirKeyRef.current) {
|
||||
setProviderOrder({ directory, order: saved });
|
||||
setToast(lang === "zh" ? "供应商顺序已保存" : "Provider order saved");
|
||||
}
|
||||
return true;
|
||||
} catch (nextError) {
|
||||
if (request === providerOrderRequestRef.current && normalizedConfigDirForComparison(directory) === activeConfigDirKeyRef.current) {
|
||||
setProviderOrder(previous);
|
||||
setError(String(nextError));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
endActionBusy(actionToken);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleSessions = React.useMemo(
|
||||
() => (sessionStatus?.sessions || []).filter((item) => showInternalSessions || !item.isSubagent),
|
||||
@@ -1342,6 +1389,7 @@ function App() {
|
||||
}, [beginLoading, endLoading]);
|
||||
|
||||
const refresh = React.useCallback((includeDiagnostics: boolean) => {
|
||||
if (Array.from(actionBusyTokensRef.current.values()).includes("reorderProviders")) return;
|
||||
invalidatePromptDetail();
|
||||
const requestId = ++refreshRequestRef.current;
|
||||
const profilesRequestId = ++officialProfilesRequestRef.current;
|
||||
@@ -1389,7 +1437,16 @@ function App() {
|
||||
setStartupDiagnosticsLoading(false);
|
||||
}
|
||||
|
||||
void invoke<CodexState>("get_codex_state", { configDir: resolvedConfigDir })
|
||||
void invoke<{ changed: boolean }>("repair_provider_model_catalog", { configDir: resolvedConfigDir })
|
||||
.then((repair) => {
|
||||
if (repair.changed && requestId === refreshRequestRef.current) {
|
||||
setToast(lang === "zh" ? "已修复第三方模型的图片输入能力,请重启 Codex 加载新菜单" : "Third-party model image input repaired. Restart Codex to reload the model menu.");
|
||||
}
|
||||
})
|
||||
.catch((repairError) => {
|
||||
if (requestId === refreshRequestRef.current) setError(String(repairError));
|
||||
})
|
||||
.then(() => invoke<CodexState>("get_codex_state", { configDir: resolvedConfigDir }))
|
||||
.then((next) => {
|
||||
if (requestId !== refreshRequestRef.current) return;
|
||||
if (normalizedConfigDirForComparison(next.codexDir)
|
||||
@@ -1437,7 +1494,7 @@ function App() {
|
||||
setRefreshing(false);
|
||||
setError(String(nextError));
|
||||
});
|
||||
}, [clearActionBusy, configDir, configDirDraft, invalidatePromptDetail, state?.codexDir]);
|
||||
}, [clearActionBusy, configDir, configDirDraft, invalidatePromptDetail, lang, state?.codexDir]);
|
||||
|
||||
// Independent of get_codex_state: this must still work when a broken TOML
|
||||
// prevents the normal app state from loading. No shared loading flags change.
|
||||
@@ -1858,7 +1915,8 @@ function App() {
|
||||
model: providerForm.model.trim(),
|
||||
apiKey: (providerForm.apiKey || "").trim(),
|
||||
tomlConfig: tomlConfig.trimEnd(),
|
||||
wireApi: providerForm.wireApi || "responses",
|
||||
wireApi: "responses",
|
||||
upstreamApi: providerUpstreamApi(providerForm),
|
||||
requiresOpenaiAuth: providerForm.requiresOpenaiAuth,
|
||||
});
|
||||
|
||||
@@ -1866,6 +1924,9 @@ function App() {
|
||||
if (savedProviders.some((saved) => saved.id === provider.id)) {
|
||||
return invoke<ActionResult>("activate_saved_provider", { configDir: configDir || null, providerId: provider.id });
|
||||
}
|
||||
if (providerRequiresConversion(provider)) {
|
||||
return Promise.reject(lang === "zh" ? "请先保存供应商,并在设置中开启本地路由和配置接管后启用。" : "Save this provider and enable the local router with config takeover in Settings before activating it.");
|
||||
}
|
||||
const tomlConfig = provider.tomlConfig?.trim();
|
||||
if (tomlConfig) {
|
||||
return invoke<ActionResult>("save_provider_toml_config", {
|
||||
@@ -1970,9 +2031,9 @@ function App() {
|
||||
const fetchProviderModels = async () => {
|
||||
const baseUrl = providerForm.baseUrl.trim();
|
||||
const apiKey = (providerForm.apiKey || "").trim();
|
||||
if (!baseUrl || !apiKey) {
|
||||
if (!baseUrl) {
|
||||
setError("");
|
||||
setToast(lang === "zh" ? "请先填写 API 请求地址和 API Key" : "Enter the API URL and API key first");
|
||||
setToast(lang === "zh" ? "请先填写 API 请求地址;认证可使用 API Key 或供应商 Headers" : "Enter the API URL first. Authentication can use an API key or provider headers");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1982,7 +2043,7 @@ function App() {
|
||||
setError("");
|
||||
setToast(lang === "zh" ? "正在获取模型列表..." : "Fetching model list...");
|
||||
try {
|
||||
const result = await invoke<ProviderModelsResult>("fetch_provider_models", { baseUrl, apiKey });
|
||||
const result = await invoke<ProviderModelsResult>("fetch_provider_models", { baseUrl, apiKey, upstreamApi: providerUpstreamApi(providerForm), configText: providerTomlDraft });
|
||||
if (providerModelsRequestRef.current !== requestId) return;
|
||||
setAvailableProviderModels(result.models);
|
||||
setToast(result.models.length > 0
|
||||
@@ -1997,13 +2058,13 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const testProvider = async (id: string, baseUrl: string, apiKey?: string | null) => {
|
||||
const testProvider = async (id: string, baseUrl: string, apiKey?: string | null, provider?: SavedProvider) => {
|
||||
const actionToken = beginActionBusy("testProvider");
|
||||
setProviderTestingId(id);
|
||||
setError("");
|
||||
setToast(lang === "zh" ? "正在检测连接..." : "Testing connection...");
|
||||
try {
|
||||
const result = await invoke<ProviderConnectionResult>("test_provider_connection", { baseUrl, apiKey: apiKey || null });
|
||||
const result = await invoke<ProviderConnectionResult>("test_provider_connection", { baseUrl, apiKey: apiKey || null, upstreamApi: provider ? providerUpstreamApi(provider) : "responses", configText: provider?.tomlConfig || null });
|
||||
if (result.ok) {
|
||||
setToast(lang === "zh" ? `连接成功,响应延迟 ${result.durationMs}ms` : `Connected, ${result.durationMs}ms latency`);
|
||||
} else {
|
||||
@@ -2127,9 +2188,11 @@ function App() {
|
||||
|
||||
const checkForUpdates = React.useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
||||
setReleaseInfo({ status: "checking" });
|
||||
let nativeCheckAttempted = false;
|
||||
try {
|
||||
if (aboutInfo?.nativeUpdaterSupported !== false) {
|
||||
const updaterResult = await appUpdater.check({ force: !quiet, timeout: 15_000 });
|
||||
nativeCheckAttempted = true;
|
||||
const updaterResult = await appUpdater.check({ force: !quiet, timeout: 30_000 });
|
||||
if (updaterResult === "available") {
|
||||
const snapshot = appUpdater.getSnapshot();
|
||||
const latestVersion = snapshot.latestVersion || "";
|
||||
@@ -2155,25 +2218,27 @@ function App() {
|
||||
latestVersion: aboutInfo?.appVersion,
|
||||
htmlUrl: `https://github.com/${FALLBACK_GITHUB_REPO}/releases/latest`,
|
||||
hasUpdate: false,
|
||||
updateMethod: "native",
|
||||
});
|
||||
if (!quiet) setToast(lang === "zh" ? "当前已是最新版本" : "You are up to date");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the existing lightweight release check as a manual-download fallback for
|
||||
// bootstrap and portable builds that cannot use the native updater yet.
|
||||
// Metadata is useful even when the native channel is unavailable. It
|
||||
// cannot supply an updater RID or replace the signed installation check.
|
||||
const update = await invoke<AppUpdateInfo>("check_app_update");
|
||||
const message = update.hasUpdate
|
||||
? (lang === "zh" ? "发现新版本" : "Update available")
|
||||
: (lang === "zh" ? "当前已是最新版本" : "You are up to date");
|
||||
setReleaseInfo({
|
||||
status: "ok",
|
||||
latestVersion: update.latestVersion,
|
||||
htmlUrl: update.htmlUrl,
|
||||
hasUpdate: update.hasUpdate,
|
||||
updateMethod: update.hasUpdate ? "download" : undefined,
|
||||
});
|
||||
setReleaseInfo(releaseInfoFromFallback(update, nativeCheckAttempted));
|
||||
if (nativeCheckAttempted) {
|
||||
if (quiet && update.hasUpdate) {
|
||||
setToast(lang === "zh"
|
||||
? `发现新版本 ${update.latestVersion},但在线更新检查失败,可重新检查`
|
||||
: `Version ${update.latestVersion} is available, but the in-app update check failed. Retry the check`);
|
||||
} else if (!quiet) {
|
||||
setUpdatePromptOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (update.hasUpdate) {
|
||||
if (quiet) {
|
||||
setToast(lang === "zh" ? `发现新版本 ${update.latestVersion},可在概览页查看` : `New version ${update.latestVersion} is available`);
|
||||
@@ -2181,14 +2246,15 @@ function App() {
|
||||
setUpdatePromptOpen(true);
|
||||
}
|
||||
} else if (!quiet) {
|
||||
setToast(message);
|
||||
setToast(lang === "zh" ? "当前已是最新版本" : "You are up to date");
|
||||
}
|
||||
} catch {
|
||||
const message = quiet ? (lang === "zh" ? "自动检查失败" : "Auto check failed") : (lang === "zh" ? "检查失败" : "Check failed");
|
||||
setReleaseInfo({
|
||||
status: "error",
|
||||
});
|
||||
if (!quiet) setToast(message);
|
||||
setReleaseInfo(releaseInfoFromCheckFailure(nativeCheckAttempted));
|
||||
if (!quiet && nativeCheckAttempted) {
|
||||
setUpdatePromptOpen(true);
|
||||
} else if (!quiet) {
|
||||
setToast(lang === "zh" ? "检查失败" : "Check failed");
|
||||
}
|
||||
}
|
||||
}, [aboutInfo?.appVersion, aboutInfo?.nativeUpdaterSupported, lang]);
|
||||
|
||||
@@ -2597,7 +2663,8 @@ function App() {
|
||||
const newCustomProviderForm = (configText = providerCreationBase): SavedProvider => ({
|
||||
...blankProviderForm,
|
||||
model: state?.model?.trim() || blankProviderForm.model,
|
||||
wireApi: currentProvider?.wireApi?.trim() || blankProviderForm.wireApi,
|
||||
wireApi: "responses",
|
||||
upstreamApi: providerUpstreamApi(currentProvider || blankProviderForm),
|
||||
requiresOpenaiAuth: currentProvider?.requiresOpenaiAuth ?? blankProviderForm.requiresOpenaiAuth,
|
||||
tomlConfig: configText.trim(),
|
||||
});
|
||||
@@ -2719,7 +2786,8 @@ function App() {
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey || "",
|
||||
tomlConfig: state?.configText?.trim() || "",
|
||||
wireApi: provider.wireApi || "responses",
|
||||
wireApi: "responses",
|
||||
upstreamApi: providerUpstreamApi(provider),
|
||||
requiresOpenaiAuth: provider.requiresOpenaiAuth,
|
||||
};
|
||||
setProviderForm(next);
|
||||
@@ -2981,7 +3049,10 @@ function App() {
|
||||
latestVersion={releaseInfo.latestVersion}
|
||||
onClose={() => setUpdatePromptOpen(false)}
|
||||
onUpdate={releaseInfo.updateMethod === "native" ? updater.downloadAndInstall : undefined}
|
||||
onRetry={releaseInfo.updateMethod === "native" ? updater.retry : undefined}
|
||||
onRetry={releaseInfo.updateMethod === "native" ? async () => {
|
||||
if (updater.state.failure === "check") await checkForUpdates();
|
||||
else await updater.retry();
|
||||
} : undefined}
|
||||
onRestart={releaseInfo.updateMethod === "native" ? updater.restart : undefined}
|
||||
onDownload={() => {
|
||||
setUpdatePromptOpen(false);
|
||||
@@ -3043,7 +3114,7 @@ function App() {
|
||||
? `${state.agentsPath} (${lang === "zh" ? "追加模式" : "append"})`
|
||||
: state.instructionFile)
|
||||
: null}
|
||||
loading={loading || refreshing}
|
||||
loading={loading || refreshing || actionBusy === "reorderProviders"}
|
||||
hasUpdate={Boolean(releaseInfo.status === "ok" && releaseInfo.hasUpdate)}
|
||||
latestVersion={releaseInfo.latestVersion}
|
||||
onConfigDirChange={setConfigDirDraft}
|
||||
@@ -3069,13 +3140,16 @@ function App() {
|
||||
loading={loading}
|
||||
testingId={providerTestingId}
|
||||
actionBusy={actionBusy}
|
||||
orderBusy={providerOrder.directory !== state.codexDir}
|
||||
onReorderProviders={saveProviderOrder}
|
||||
editingProviderId={editingProviderId || (editingDetectedProvider ? providerForm.id : null)}
|
||||
providerForm={{
|
||||
apiKey: providerForm.apiKey || "",
|
||||
baseUrl: providerForm.baseUrl,
|
||||
providerName: providerForm.providerName,
|
||||
model: providerForm.model,
|
||||
wireApi: providerForm.wireApi,
|
||||
wireApi: "responses",
|
||||
upstreamApi: providerUpstreamApi(providerForm),
|
||||
requiresOpenaiAuth: providerForm.requiresOpenaiAuth,
|
||||
}}
|
||||
officialForm={officialForm}
|
||||
@@ -3116,7 +3190,7 @@ function App() {
|
||||
}}
|
||||
onTestProvider={(row) => {
|
||||
const local = findLocalProviderForRow(row);
|
||||
void testProvider(row.testingKey || `${row.source}-${row.id}`, row.baseUrl, local?.apiKey || row.apiKey || null);
|
||||
void testProvider(row.testingKey || `${row.source}-${row.id}`, row.baseUrl, local?.apiKey || row.apiKey || null, local);
|
||||
}}
|
||||
onEditProvider={(row) => {
|
||||
if (row.source === "official") {
|
||||
@@ -3172,7 +3246,10 @@ function App() {
|
||||
}))}
|
||||
onProviderModelChange={(value) => setProviderForm((current) => ({ ...current, model: value }))}
|
||||
onFetchModels={() => void fetchProviderModels()}
|
||||
onWireApiChange={(value) => setProviderForm((current) => ({ ...current, wireApi: value }))}
|
||||
onUpstreamApiChange={(value) => {
|
||||
resetAvailableProviderModels();
|
||||
setProviderForm((current) => ({ ...current, upstreamApi: value, wireApi: "responses" }));
|
||||
}}
|
||||
onRequiresAuthChange={(value) => setProviderForm((current) => ({ ...current, requiresOpenaiAuth: value }))}
|
||||
onToggleApiKeyVisibility={() => setProviderApiKeyVisible((value) => !value)}
|
||||
onProviderTomlDraftChange={(value, origin = "manual") => {
|
||||
@@ -3181,6 +3258,11 @@ function App() {
|
||||
setProviderTomlDirty(true);
|
||||
if (origin === "manual") setProviderCommonConfigDirty(true);
|
||||
}}
|
||||
onProviderHeadersConfigChange={(value) => {
|
||||
providerDraftRequestRef.current += 1;
|
||||
setProviderTomlDraft(value);
|
||||
setProviderForm((current) => ({ ...current, tomlConfig: value }));
|
||||
}}
|
||||
onResetProviderToml={() => {
|
||||
providerDraftRequestRef.current += 1;
|
||||
setProviderTomlDraft(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
EyeOff,
|
||||
FilePlus2,
|
||||
Gauge,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
@@ -24,10 +25,13 @@ import { PageTransition } from "../components/PageTransition";
|
||||
import { OfficialQuotaDialog } from "../components/OfficialQuotaDialog";
|
||||
import { OfficialAccountBadge } from "../components/OfficialAccountBadge";
|
||||
import { ProviderModelMappings, validateProviderModelMappings } from "../components/ProviderModelMappings";
|
||||
import { ProviderHeadersControl } from "../components/ProviderHeadersControl";
|
||||
import { providerUpstreamApi } from "../providerProtocol";
|
||||
import { ProviderPresetPicker } from "../components/ProviderPresetPicker";
|
||||
import { PROVIDER_PRESETS, getProviderPreset, getProviderPresetVariant } from "../providerPresets";
|
||||
import { Button, Checkbox, ModalShell } from "../components/ui";
|
||||
import type { ProviderMode, ProviderModelMapping } from "../types";
|
||||
import { moveProviderRow, providerRowKey } from "../providerRowOrder";
|
||||
import "../styles/providers-page.css";
|
||||
|
||||
export type ProviderRowSource = "official" | "local" | "detected";
|
||||
@@ -38,8 +42,10 @@ export type ProviderRow = {
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
modelDisplayName?: string;
|
||||
apiKey?: string;
|
||||
wireApi: string;
|
||||
upstreamApi?: string | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
isCurrent: boolean;
|
||||
isDefaultOfficial?: boolean;
|
||||
@@ -62,6 +68,7 @@ export type ProviderFormValue = {
|
||||
providerName: string;
|
||||
model: string;
|
||||
wireApi: string;
|
||||
upstreamApi?: string | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
};
|
||||
|
||||
@@ -149,6 +156,8 @@ export type ProvidersPageProps = {
|
||||
loading: boolean;
|
||||
testingId: string;
|
||||
actionBusy?: string;
|
||||
orderBusy?: boolean;
|
||||
onReorderProviders: (order: string[]) => Promise<boolean>;
|
||||
editingProviderId: string | null;
|
||||
creatingProvider: boolean;
|
||||
selectedPresetId: string;
|
||||
@@ -191,10 +200,11 @@ export type ProvidersPageProps = {
|
||||
onProviderNameChange: (value: string) => void;
|
||||
onProviderModelChange: (value: string) => void;
|
||||
onFetchModels: () => void;
|
||||
onWireApiChange: (value: string) => void;
|
||||
onUpstreamApiChange: (value: string) => void;
|
||||
onRequiresAuthChange: (value: boolean) => void;
|
||||
onToggleApiKeyVisibility: () => void;
|
||||
onProviderTomlDraftChange: (value: string, origin?: "manual" | "context") => void;
|
||||
onProviderHeadersConfigChange: (value: string) => void;
|
||||
onResetProviderToml: () => void;
|
||||
onSaveProvider: () => void;
|
||||
};
|
||||
@@ -394,6 +404,7 @@ function ListPage({
|
||||
loading,
|
||||
testingId,
|
||||
actionBusy,
|
||||
orderBusy,
|
||||
onImportCcSwitch,
|
||||
onAddProvider,
|
||||
onEnableProvider,
|
||||
@@ -401,11 +412,76 @@ function ListPage({
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
onReorderProviders,
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "orderBusy" | "onReorderProviders" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [quotaSelection, setQuotaSelection] = useState<{ id: string; configDir: string; email: string | null } | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
const sortingDisabled = providerActionsBusy || Boolean(orderBusy) || providerRows.length < 2;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pointerDragRef = useRef<{ key: string; pointerId: number; startY: number; moved: boolean } | null>(null);
|
||||
const pointerPositionRef = useRef({ x: 0, y: 0 });
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const dropTargetRef = useRef<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const [draggedKey, setDraggedKey] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const finishDrag = () => {
|
||||
if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = null;
|
||||
pointerDragRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
setDraggedKey("");
|
||||
setDropTarget(null);
|
||||
};
|
||||
useEffect(() => { finishDrag(); }, [configDir]);
|
||||
useEffect(() => () => { if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current); }, []);
|
||||
const reorder = async (source: string, target: string, position: "before" | "after") => {
|
||||
const current = providerRows.map(providerRowKey);
|
||||
const next = moveProviderRow(current, source, target, position);
|
||||
if (next.some((key, index) => key !== current[index])) {
|
||||
await onReorderProviders(next);
|
||||
if (document.activeElement === document.body || document.activeElement?.classList.contains("cx-providers-drag-handle")) {
|
||||
const card = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-provider-key]") || []).find((item) => item.dataset.providerKey === source);
|
||||
card?.querySelector<HTMLButtonElement>(".cx-providers-drag-handle")?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
const updateDropTarget = (clientX: number, clientY: number) => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (clientX < bounds.left || clientX > bounds.right) {
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const cards = Array.from(list.querySelectorAll<HTMLElement>("[data-provider-key]"));
|
||||
const card = cards.find((item) => clientY < item.getBoundingClientRect().bottom) || cards[cards.length - 1];
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const target = { key: card.dataset.providerKey!, position: clientY < rect.top + rect.height / 2 ? "before" as const : "after" as const };
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
const startAutoScroll = () => {
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
const tick = () => {
|
||||
const list = listRef.current;
|
||||
if (!pointerDragRef.current?.moved || !list) { scrollFrameRef.current = null; return; }
|
||||
const { x, y } = pointerPositionRef.current;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (x >= bounds.left && x <= bounds.right) {
|
||||
const speed = y < bounds.top + 48 ? -Math.min(16, (bounds.top + 48 - y) / 3)
|
||||
: y > bounds.bottom - 48 ? Math.min(16, (y - bounds.bottom + 48) / 3) : 0;
|
||||
const previous = list.scrollTop;
|
||||
list.scrollTop += speed;
|
||||
if (list.scrollTop !== previous) updateDropTarget(x, y);
|
||||
}
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
const quotaProfile = quotaSelection?.configDir === configDir
|
||||
? providerRows.find((row) => row.source === "official" && row.id === quotaSelection.id && row.canQueryQuota && (row.email ?? null) === quotaSelection.email)
|
||||
: undefined;
|
||||
@@ -453,15 +529,58 @@ function ListPage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="cx-providers-list" role="list">
|
||||
<p className="cx-providers-sort-hint">{lang === "zh" ? "拖动卡片左侧手柄调整顺序,自动保存。" : "Drag the handle on the left to reorder. Changes save automatically."}</p>
|
||||
<div className="cx-providers-list" role="list" ref={listRef}>
|
||||
{providerRows.length === 0 ? (
|
||||
<div className="cx-providers-empty" role="status">{copy.noProviders}</div>
|
||||
) : providerRows.map((row) => {
|
||||
const testingKey = row.testingKey || `${row.source}-${row.id}`;
|
||||
const isTesting = testingId === testingKey;
|
||||
const isProtectedOfficial = row.source === "official" && row.isDefaultOfficial === true;
|
||||
const key = providerRowKey(row);
|
||||
const modelName = row.modelDisplayName || row.model || (lang === "zh" ? "跟随 Codex" : "Follow Codex");
|
||||
return (
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}${draggedKey === key ? " cx-providers-row--dragging" : ""}${dropTarget?.key === key && draggedKey !== key ? ` cx-providers-row--drop-${dropTarget.position}` : ""}`} key={key} role="listitem" data-provider-key={key}>
|
||||
<button type="button" className="cx-providers-drag-handle" disabled={sortingDisabled}
|
||||
aria-label={lang === "zh" ? `排序 ${row.providerName}:拖动或按上下箭头移动` : `Reorder ${row.providerName}: drag or use Up and Down arrows`}
|
||||
title={lang === "zh" ? "拖动排序,也可按上下箭头移动" : "Drag to reorder, or use Up and Down arrows"}
|
||||
onPointerDown={(event) => {
|
||||
if (sortingDisabled || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
pointerDragRef.current = { key, pointerId: event.pointerId, startY: event.clientY, moved: false };
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId || sortingDisabled) return;
|
||||
if (!drag.moved && Math.abs(event.clientY - drag.startY) < 5) return;
|
||||
drag.moved = true;
|
||||
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
|
||||
setDraggedKey(drag.key);
|
||||
updateDropTarget(event.clientX, event.clientY);
|
||||
startAutoScroll();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
const target = dropTargetRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
finishDrag();
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
const bounds = listRef.current?.getBoundingClientRect();
|
||||
const inside = bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom;
|
||||
if (drag.moved && target && inside && !sortingDisabled) void reorder(drag.key, target.key, target.position);
|
||||
}}
|
||||
onPointerCancel={finishDrag}
|
||||
onLostPointerCapture={finishDrag}
|
||||
onKeyDown={(event) => {
|
||||
if (sortingDisabled || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const index = providerRows.findIndex((entry) => providerRowKey(entry) === key);
|
||||
const target = providerRows[index + (event.key === "ArrowUp" ? -1 : 1)];
|
||||
if (target) void reorder(key, providerRowKey(target), event.key === "ArrowUp" ? "before" : "after");
|
||||
}}
|
||||
><GripVertical size={16} aria-hidden="true" /></button>
|
||||
<ProviderAvatar row={row} />
|
||||
<div className="cx-providers-row-content">
|
||||
<div className="cx-providers-row-main">
|
||||
@@ -474,6 +593,7 @@ function ListPage({
|
||||
)}
|
||||
</div>
|
||||
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
|
||||
<div className="cx-providers-row-model" title={row.model || modelName}><span>{lang === "zh" ? "模型" : "Model"}</span><b>{modelName}</b>{row.modelDisplayName && row.modelDisplayName !== row.model && <code>{row.model}</code>}</div>
|
||||
{row.source !== "official" && row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -744,17 +864,21 @@ function ProviderForm({
|
||||
onProviderNameChange,
|
||||
onProviderModelChange,
|
||||
onFetchModels,
|
||||
onWireApiChange,
|
||||
onUpstreamApiChange,
|
||||
onRequiresAuthChange,
|
||||
onToggleApiKeyVisibility,
|
||||
onProviderTomlDraftChange,
|
||||
onProviderHeadersConfigChange,
|
||||
onResetProviderToml,
|
||||
onSaveProvider,
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "providerForm" | "providerModelMappings" | "onProviderModelMappingsChange" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "providerForm" | "providerModelMappings" | "onProviderModelMappingsChange" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onUpstreamApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onProviderHeadersConfigChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
const modelListId = useId();
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy;
|
||||
const [headersBusy, setHeadersBusy] = useState(false);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
const [headersRevision, setHeadersRevision] = useState(0);
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim());
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy || headersBusy;
|
||||
const mappingsValid = validateProviderModelMappings(providerModelMappings, providerForm.model, lang).valid;
|
||||
|
||||
return (
|
||||
@@ -803,7 +927,7 @@ function ProviderForm({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--secondary cx-providers-button--small cx-providers-fetch-models"
|
||||
onClick={onFetchModels}
|
||||
disabled={loading || fetchingModels || !canFetchModels}
|
||||
disabled={formBusy || !canFetchModels || !headersValid}
|
||||
title={copy.fetchModelsLabel}
|
||||
aria-label={copy.fetchModelsLabel}
|
||||
>
|
||||
@@ -833,12 +957,22 @@ function ProviderForm({
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={copy.wireApiLabel}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)} disabled={formBusy}>
|
||||
<option value="responses">responses</option>
|
||||
<option value="chat">chat</option>
|
||||
<Field label={lang === "zh" ? "上游接口协议" : "Upstream API protocol"}>
|
||||
<select value={providerUpstreamApi(providerForm)} onChange={(event) => onUpstreamApiChange(event.target.value)} disabled={formBusy}>
|
||||
<option value="responses">OpenAI Responses</option>
|
||||
<option value="chat_completions">OpenAI Chat Completions</option>
|
||||
<option value="anthropic_messages">Claude Messages</option>
|
||||
<option value="gemini">Gemini generateContent</option>
|
||||
{!["responses", "chat_completions", "anthropic_messages", "gemini"].includes(providerUpstreamApi(providerForm)) && <option value={providerUpstreamApi(providerForm)} disabled>{lang === "zh" ? "不支持的协议,请重新选择" : "Unsupported protocol — choose a supported API"}</option>}
|
||||
</select>
|
||||
</Field>
|
||||
{providerUpstreamApi(providerForm) !== "responses" && (
|
||||
<p className="cx-providers-field--full cx-providers-protocol-hint">
|
||||
{lang === "zh"
|
||||
? "Codex 仍使用 Responses。本协议需在设置中开启本地路由和配置接管;使用期间请保持 Codex-X 运行。关闭路由前请切回 Responses 供应商或官方账号;退出期间请求会暂停,重开后自动恢复。"
|
||||
: "Codex still uses Responses. Enable the local router and config takeover in Settings, and keep Codex-X running. Switch to a Responses provider or official account before disabling the router. Requests pause while Codex-X is closed and resume after reopening."}
|
||||
</p>
|
||||
)}
|
||||
<Checkbox
|
||||
className="cx-providers-checkbox cx-providers-checkbox--full"
|
||||
checked={providerForm.requiresOpenaiAuth}
|
||||
@@ -849,6 +983,16 @@ function ProviderForm({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProviderHeadersControl
|
||||
key={`headers:${editingProviderId ?? "new-provider"}:${selectedPresetId}:${selectedPresetVariantId}:${headersRevision}`}
|
||||
lang={lang}
|
||||
configText={providerTomlDraft}
|
||||
disabled={loading || fetchingModels || contextWindowBusy}
|
||||
onConfigChange={onProviderHeadersConfigChange}
|
||||
onBusyChange={setHeadersBusy}
|
||||
onValidityChange={setHeadersValid}
|
||||
/>
|
||||
|
||||
<ProviderModelMappings
|
||||
key={editingProviderId ?? "new-provider"}
|
||||
lang={lang}
|
||||
@@ -869,15 +1013,16 @@ function ProviderForm({
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<div className="cx-providers-context-actions">
|
||||
<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>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => { setHeadersRevision((value) => value + 1); onResetProviderToml(); }} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" aria-label={copy.tomlTitle} value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" aria-label={copy.tomlTitle} value={providerTomlDraft} onChange={(event) => { setHeadersRevision((value) => value + 1); onProviderTomlDraftChange(event.target.value); }} disabled={formBusy} spellCheck={false} />
|
||||
</section>
|
||||
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
{!headersValid && <span className="cx-provider-mappings-save-hint">{lang === "zh" ? "请先修正请求头中的错误。" : "Correct the HTTP header errors before saving."}</span>}
|
||||
{!mappingsValid && <span className="cx-provider-mappings-save-hint">{lang === "zh" ? "请先修正模型映射中的错误。" : "Correct the model mapping errors before saving."}</span>}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy || !mappingsValid}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy || !mappingsValid || !headersValid}>
|
||||
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{loading ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,19 +1,70 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Folder,
|
||||
FolderTree,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
Pin,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Checkbox, ModalShell, cx } from "../components/ui";
|
||||
import {
|
||||
mergeSessionDirectoryGroups,
|
||||
pinnedSessionDirectoryKeys,
|
||||
readSessionPinPreferences,
|
||||
sessionPinStorageKey,
|
||||
sessionsForDirectoryGroup,
|
||||
toggleFolderPins,
|
||||
type SessionDirectoryIdentities,
|
||||
type SessionPinPreferences,
|
||||
} from "../sessionDirectoryIdentity";
|
||||
import "../styles/session-management.css";
|
||||
|
||||
const SESSION_ROW_HEIGHT = 58;
|
||||
const SESSION_COLUMN_HEADER_HEIGHT = 34;
|
||||
const SESSION_VIRTUAL_OVERSCAN_ROWS = 8;
|
||||
const DEFAULT_SESSION_VIEWPORT_HEIGHT = 640;
|
||||
const SESSION_ALL_FOLDERS_KEY = "__codexx_all_folders__";
|
||||
// Defer the browser storage getter so unavailable storage is caught by the reader.
|
||||
const sessionPinStorage = { getItem: (key: string) => localStorage.getItem(key) };
|
||||
|
||||
type ScopedSessionPinPreferences = SessionPinPreferences & {
|
||||
storageKey: string;
|
||||
};
|
||||
|
||||
function folderDisplayName(value: string, missing: string) {
|
||||
if (!value) return missing;
|
||||
const parts = value.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || missing;
|
||||
}
|
||||
|
||||
type SessionVirtualRow = {
|
||||
kind: "session";
|
||||
key: string;
|
||||
top: number;
|
||||
height: number;
|
||||
item: SessionPreview;
|
||||
};
|
||||
|
||||
function findVirtualRowIndex(rows: SessionVirtualRow[], offset: number) {
|
||||
let low = 0;
|
||||
let high = rows.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (rows[middle].top + rows[middle].height <= offset) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
export type Lang = "zh" | "en";
|
||||
|
||||
export type SessionPreview = {
|
||||
@@ -32,6 +83,8 @@ export type SessionPreview = {
|
||||
|
||||
export type SessionSyncStatus = {
|
||||
codexDir: string;
|
||||
directoryIdentities?: SessionDirectoryIdentities;
|
||||
pinScopeKey?: string | null;
|
||||
targetProvider: string;
|
||||
rolloutFiles: number;
|
||||
sessionMetaCount: number;
|
||||
@@ -157,6 +210,7 @@ export function SessionManagementPage({
|
||||
onDeleteSafetyConfirmedChange,
|
||||
}: SessionManagementPageProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const codexDir = sessionStatus?.codexDir || "default";
|
||||
const copy = isChinese
|
||||
? {
|
||||
syncEyebrow: "会话同步",
|
||||
@@ -170,14 +224,14 @@ export function SessionManagementPage({
|
||||
clickToCheck: "点击检查会话",
|
||||
scanIncomplete: "无法确认同步状态,请查看下方原因",
|
||||
needsSync: (count: number) => `有 ${count} 条会话需要同步`,
|
||||
allSynced: "普通会话已同步",
|
||||
allSynced: "全部会话已同步",
|
||||
sessionCount: (count: number) => `${count} 条会话`,
|
||||
local: "本地会话",
|
||||
list: "会话列表",
|
||||
list: "文件夹与会话",
|
||||
shown: (shown: number, total: number) => `展示 ${shown} / ${total} 条`,
|
||||
loaded: (count: number) => `当前加载 ${count} 条`,
|
||||
search: "搜索标题 / 项目 / 供应商 / ID",
|
||||
groupByProject: "按项目路径分组",
|
||||
groupByProject: "文件夹视图",
|
||||
showInternal: (count: number) => `显示内部会话 (${count})`,
|
||||
deleteSelected: "删除选中",
|
||||
exportSelected: "导出选中",
|
||||
@@ -189,6 +243,12 @@ export function SessionManagementPage({
|
||||
selectProject: (path: string, count: number) => `选择项目 ${path} 的 ${count} 条会话`,
|
||||
projectCount: (count: number, truncated: boolean) => `${truncated ? "已加载 " : ""}${count} 条`,
|
||||
projectShown: (shown: number, total: number) => `显示 ${shown} / 共 ${total} 条`,
|
||||
pinFolder: "置顶文件夹",
|
||||
unpinFolder: "取消置顶文件夹",
|
||||
pinSession: "置顶会话",
|
||||
unpinSession: "取消置顶会话",
|
||||
allSessions: "全部会话",
|
||||
folders: "工作文件夹",
|
||||
selectSession: "选择会话",
|
||||
archived: "已归档",
|
||||
internal: "内部",
|
||||
@@ -198,6 +258,7 @@ export function SessionManagementPage({
|
||||
noModel: "未记录",
|
||||
noMatch: "没有匹配的会话。",
|
||||
noSessions: "还没有读取到会话。点击右上角“检查会话”刷新。",
|
||||
loadingMore: "正在加载更多会话…",
|
||||
diagnostics: "诊断信息",
|
||||
diagnosticsCount: (count: number) => `${count} 条 · 点击查看`,
|
||||
deleteTitle: (count: number) => `永久删除 ${count} 条会话`,
|
||||
@@ -224,14 +285,14 @@ 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: "User conversations are synced",
|
||||
allSynced: "All sessions are synced",
|
||||
sessionCount: (count: number) => `${count} sessions`,
|
||||
local: "LOCAL SESSIONS",
|
||||
list: "Sessions",
|
||||
list: "Folders & sessions",
|
||||
shown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
loaded: (count: number) => `${count} loaded`,
|
||||
search: "Search title / project / provider / ID",
|
||||
groupByProject: "Group by project path",
|
||||
groupByProject: "Folder view",
|
||||
showInternal: (count: number) => `Show internal sessions (${count})`,
|
||||
deleteSelected: "Delete selected",
|
||||
exportSelected: "Export selected",
|
||||
@@ -243,6 +304,12 @@ export function SessionManagementPage({
|
||||
selectProject: (path: string, count: number) => `Select ${count} sessions in ${path}`,
|
||||
projectCount: (count: number, truncated: boolean) => `${count}${truncated ? " loaded" : ""}`,
|
||||
projectShown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
pinFolder: "Pin folder",
|
||||
unpinFolder: "Unpin folder",
|
||||
pinSession: "Pin session",
|
||||
unpinSession: "Unpin session",
|
||||
allSessions: "All sessions",
|
||||
folders: "Workspace folders",
|
||||
selectSession: "Select session",
|
||||
archived: "Archived",
|
||||
internal: "Internal",
|
||||
@@ -252,6 +319,7 @@ export function SessionManagementPage({
|
||||
noModel: "Not recorded",
|
||||
noMatch: "No matching sessions.",
|
||||
noSessions: "No sessions loaded. Click Check sessions to refresh.",
|
||||
loadingMore: "Loading more sessions…",
|
||||
diagnostics: "Diagnostics",
|
||||
diagnosticsCount: (count: number) => `${count} · click to view`,
|
||||
deleteTitle: (count: number) => `Permanently delete ${count} session(s)`,
|
||||
@@ -273,10 +341,173 @@ export function SessionManagementPage({
|
||||
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
|
||||
];
|
||||
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
|
||||
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
|
||||
const sessionScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const directoryIdentities = sessionStatus?.directoryIdentities;
|
||||
const pinScopeKey = sessionStatus?.pinScopeKey;
|
||||
const pinStorageKey = useMemo(() => sessionPinStorageKey(codexDir, pinScopeKey), [codexDir, pinScopeKey]);
|
||||
const [pinPreferences, setPinPreferences] = useState<ScopedSessionPinPreferences>({
|
||||
storageKey: "",
|
||||
folders: [],
|
||||
sessions: [],
|
||||
});
|
||||
const [selectedFolderKey, setSelectedFolderKey] = useState(SESSION_ALL_FOLDERS_KEY);
|
||||
const [sessionScrollMetrics, setSessionScrollMetrics] = useState({
|
||||
scrollTop: 0,
|
||||
viewportHeight: DEFAULT_SESSION_VIEWPORT_HEIGHT,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPinPreferences({ storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) });
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}, [pinStorageKey]);
|
||||
|
||||
const activePinPreferences = pinPreferences.storageKey === pinStorageKey
|
||||
? pinPreferences
|
||||
: { storageKey: pinStorageKey, folders: [], sessions: [] };
|
||||
const pinnedFolderSet = useMemo(() => pinnedSessionDirectoryKeys(activePinPreferences.folders, directoryIdentities), [activePinPreferences.folders, directoryIdentities]);
|
||||
const pinnedSessionSet = useMemo(() => new Set(activePinPreferences.sessions), [activePinPreferences.sessions]);
|
||||
|
||||
const updatePins = useCallback((kind: "folders" | "sessions", key: string) => {
|
||||
setPinPreferences((current) => {
|
||||
const base = current.storageKey === pinStorageKey
|
||||
? current
|
||||
: { storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) };
|
||||
const values = new Set(base.sessions);
|
||||
if (kind === "sessions") {
|
||||
if (values.has(key)) values.delete(key);
|
||||
else values.add(key);
|
||||
}
|
||||
const next = kind === "folders"
|
||||
? { ...base, folders: toggleFolderPins(base.folders, key, directoryIdentities) }
|
||||
: { ...base, sessions: Array.from(values) };
|
||||
try {
|
||||
localStorage.setItem(pinStorageKey, JSON.stringify({ folders: next.folders, sessions: next.sessions }));
|
||||
} catch {
|
||||
// Pinning remains available for this run even when localStorage is unavailable.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [pinStorageKey, directoryIdentities]);
|
||||
|
||||
const orderedSessionGroups = useMemo(() => mergeSessionDirectoryGroups(groupedSessions, directoryIdentities).map(({ group, folderKey, items }) => {
|
||||
const orderedItems = [...items].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
});
|
||||
return {
|
||||
group,
|
||||
folderKey,
|
||||
items: orderedItems,
|
||||
newestAt: orderedItems.reduce((latest, item) => Math.max(latest, item.updatedAtMs || 0), 0),
|
||||
pinned: pinnedFolderSet.has(folderKey),
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
if (!sessionGroupByCwd) return 0;
|
||||
const pinnedOrder = Number(right.pinned) - Number(left.pinned);
|
||||
return pinnedOrder || right.newestAt - left.newestAt || left.group.localeCompare(right.group);
|
||||
}), [groupedSessions, directoryIdentities, pinnedFolderSet, pinnedSessionSet, sessionGroupByCwd]);
|
||||
|
||||
const orderedAllSessions = useMemo(() => [...filteredSessions].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
}), [filteredSessions, pinnedSessionSet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return;
|
||||
if (!orderedSessionGroups.some((group) => group.folderKey === selectedFolderKey)) {
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}
|
||||
}, [orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
|
||||
const displayedSessions = useMemo(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return orderedAllSessions;
|
||||
return sessionsForDirectoryGroup(orderedSessionGroups, selectedFolderKey);
|
||||
}, [orderedAllSessions, orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
const selectedVisibleCount = displayedSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = displayedSessions.length > 0 && selectedVisibleCount === displayedSessions.length;
|
||||
const visibleSelectionIsPartial = selectedVisibleCount > 0 && !allVisibleSelected;
|
||||
|
||||
const virtualSessionModel = useMemo(() => {
|
||||
const rows: SessionVirtualRow[] = [];
|
||||
let top = 0;
|
||||
displayedSessions.forEach((item) => {
|
||||
rows.push({
|
||||
kind: "session",
|
||||
key: `session:${item.id}`,
|
||||
top,
|
||||
height: SESSION_ROW_HEIGHT,
|
||||
item,
|
||||
});
|
||||
top += SESSION_ROW_HEIGHT;
|
||||
});
|
||||
return { rows, totalHeight: top };
|
||||
}, [displayedSessions]);
|
||||
|
||||
const visibleSessionRows = useMemo(() => {
|
||||
const viewportHeight = sessionScrollMetrics.viewportHeight || DEFAULT_SESSION_VIEWPORT_HEIGHT;
|
||||
const bodyScrollTop = Math.max(0, sessionScrollMetrics.scrollTop - SESSION_COLUMN_HEADER_HEIGHT);
|
||||
const firstVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop);
|
||||
const lastVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop + viewportHeight);
|
||||
const firstIndex = Math.max(0, firstVisibleIndex - SESSION_VIRTUAL_OVERSCAN_ROWS);
|
||||
const lastIndex = Math.min(
|
||||
virtualSessionModel.rows.length,
|
||||
lastVisibleIndex + SESSION_VIRTUAL_OVERSCAN_ROWS + 1,
|
||||
);
|
||||
return virtualSessionModel.rows.slice(firstIndex, lastIndex);
|
||||
}, [sessionScrollMetrics, virtualSessionModel]);
|
||||
|
||||
const syncSessionScrollMetrics = useCallback(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
setSessionScrollMetrics((current) => {
|
||||
const next = {
|
||||
scrollTop: element.scrollTop,
|
||||
viewportHeight: element.clientHeight,
|
||||
};
|
||||
return current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight
|
||||
? current
|
||||
: next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSessionScroll = useCallback(() => {
|
||||
syncSessionScrollMetrics();
|
||||
}, [syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
syncSessionScrollMetrics();
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return undefined;
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", syncSessionScrollMetrics);
|
||||
return () => window.removeEventListener("resize", syncSessionScrollMetrics);
|
||||
}
|
||||
const observer = new ResizeObserver(syncSessionScrollMetrics);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [active, syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
if (element.scrollTop > maxScrollTop) {
|
||||
element.scrollTop = maxScrollTop;
|
||||
syncSessionScrollMetrics();
|
||||
}
|
||||
}, [syncSessionScrollMetrics, virtualSessionModel.totalHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
element.scrollTop = 0;
|
||||
syncSessionScrollMetrics();
|
||||
}, [selectedFolderKey, sessionGroupByCwd, syncSessionScrollMetrics]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
@@ -429,56 +660,102 @@ export function SessionManagementPage({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredSessions.length > 0 ? (
|
||||
<div className="cx-session-scroll" role="table" aria-label={copy.list}>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(filteredSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span className="cx-session-id-heading">ID</span>
|
||||
<span className="cx-session-actions-heading">{isChinese ? "导出" : "Export"}</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body">
|
||||
{groupedSessions.map(([group, items]) => {
|
||||
const projectSessions = allSessionsByCwd.get(group) || items;
|
||||
const selectedProjectCount = projectSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const projectSelected = projectSessions.length > 0 && selectedProjectCount === projectSessions.length;
|
||||
const projectPartiallySelected = selectedProjectCount > 0 && !projectSelected;
|
||||
const groupCountLabel = items.length === projectSessions.length
|
||||
? copy.projectCount(projectSessions.length, sessionPreviewTruncated)
|
||||
: copy.projectShown(items.length, projectSessions.length);
|
||||
return (
|
||||
<div className="cx-session-group" key={group}>
|
||||
{sessionGroupByCwd && (
|
||||
<label className={cx("cx-session-group-heading", projectSelected && "cx-session-group-heading--selected", projectPartiallySelected && "cx-session-group-heading--partial")}>
|
||||
<input
|
||||
className="cx-session-checkbox"
|
||||
type="checkbox"
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = projectPartiallySelected;
|
||||
}}
|
||||
checked={projectSelected}
|
||||
onChange={(event) => onSetSessionGroupSelected(projectSessions, event.target.checked)}
|
||||
aria-label={copy.selectProject(group, projectSessions.length)}
|
||||
/>
|
||||
<span title={group}>{compactPath(group, 96, isChinese ? "未记录路径" : "No path recorded")}</span>
|
||||
<em>{groupCountLabel}</em>
|
||||
</label>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<div className={cx("cx-session-browser", !sessionGroupByCwd && "cx-session-browser--flat")}>
|
||||
{sessionGroupByCwd && (
|
||||
<aside className="cx-session-folder-tree" aria-label={copy.folders}>
|
||||
<div className="cx-session-tree-heading">
|
||||
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<FolderTree size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.folders}</span>
|
||||
</div>
|
||||
<div className="cx-session-tree-items" role="tree">
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === SESSION_ALL_FOLDERS_KEY}
|
||||
className={cx("cx-session-tree-all", selectedFolderKey === SESSION_ALL_FOLDERS_KEY && "cx-session-tree-item--active")}
|
||||
onClick={() => setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY)}
|
||||
>
|
||||
<History size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.allSessions}</span>
|
||||
<em>{filteredSessions.length}</em>
|
||||
</button>
|
||||
{orderedSessionGroups.map((folder) => (
|
||||
<div
|
||||
className={cx("cx-session-tree-item", selectedFolderKey === folder.folderKey && "cx-session-tree-item--active")}
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === folder.folderKey}
|
||||
key={folder.folderKey}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-tree-select"
|
||||
onClick={() => setSelectedFolderKey(folder.folderKey)}
|
||||
title={folder.group}
|
||||
>
|
||||
<Folder size={15} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span className="cx-session-folder-copy">
|
||||
<strong>{folderDisplayName(folder.group, isChinese ? "未记录路径" : "No path recorded")}</strong>
|
||||
<small>{compactPath(folder.group, 34, isChinese ? "未记录路径" : "No path recorded")}</small>
|
||||
</span>
|
||||
<em>{folder.items.length}</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", folder.pinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("folders", folder.items.find((item) => item.cwd)?.cwd || "")}
|
||||
aria-label={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
title={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
>
|
||||
<Pin size={13} strokeWidth={1.9} fill={folder.pinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="cx-session-results">
|
||||
{displayedSessions.length > 0 ? (
|
||||
<div
|
||||
ref={sessionScrollRef}
|
||||
className="cx-session-scroll"
|
||||
role="table"
|
||||
aria-label={copy.list}
|
||||
onScroll={handleSessionScroll}
|
||||
>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(displayedSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span className="cx-session-id-heading">ID</span>
|
||||
<span className="cx-session-actions-heading">{isChinese ? "操作" : "Actions"}</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body" style={{ height: virtualSessionModel.totalHeight }}>
|
||||
{visibleSessionRows.map((row) => {
|
||||
const { item } = row;
|
||||
const sessionPinned = pinnedSessionSet.has(item.id);
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
className={cx(
|
||||
"cx-session-row",
|
||||
"cx-session-virtual-item",
|
||||
item.needsSync && !item.isSubagent && "cx-session-row--needs-sync",
|
||||
selectedSessionSet.has(item.id) && "cx-session-row--selected",
|
||||
sessionPinned && "cx-session-row--pinned",
|
||||
)}
|
||||
key={row.key}
|
||||
role="row"
|
||||
style={{ top: row.top, height: row.height }}
|
||||
onClick={(event) => {
|
||||
if (!(event.target as HTMLElement).closest("button, input") && !loading && !sessionDeleteBusy) onToggleSessionSelected(item.id);
|
||||
}}
|
||||
@@ -500,35 +777,46 @@ export function SessionManagementPage({
|
||||
{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>}
|
||||
{(!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
|
||||
</div>
|
||||
<span className="cx-session-meta cx-session-meta--time" title={item.updatedAtMs ? new Date(item.updatedAtMs).toLocaleString() : undefined}>{formatSessionTime(item.updatedAtMs, lang)}</span>
|
||||
<code className="cx-session-meta cx-session-meta--provider" title={item.modelProvider || undefined}>{item.modelProvider || copy.unknownProvider}</code>
|
||||
<span className="cx-session-meta cx-session-meta--model" title={item.model || undefined}>{item.model || copy.noModel}</span>
|
||||
<small className="cx-session-meta cx-session-meta--id" title={item.id}>#{shortId(item.id)}</small>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-row-export"
|
||||
onClick={() => onExportSessions([item.id])}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy}
|
||||
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
|
||||
title={copy.exportOne}
|
||||
>
|
||||
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="cx-session-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", sessionPinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("sessions", item.id)}
|
||||
aria-label={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
title={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
>
|
||||
<Pin size={14} strokeWidth={1.9} fill={sessionPinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-row-export"
|
||||
onClick={() => onExportSessions([item.id])}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy}
|
||||
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
|
||||
title={copy.exportOne}
|
||||
>
|
||||
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnostics.length ? (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ProviderHeader } from "./providerHeaders";
|
||||
|
||||
export const PROVIDER_HEADER_PRESETS = [
|
||||
{
|
||||
id: "claude_code",
|
||||
label: { zh: "Claude Code", en: "Claude Code" },
|
||||
name: "User-Agent",
|
||||
value: "claude-cli/2.1.161 (external, cli)",
|
||||
},
|
||||
{
|
||||
id: "kilo_code",
|
||||
label: { zh: "Kilo Code", en: "Kilo Code" },
|
||||
name: "User-Agent",
|
||||
value: "Kilo-Code/1.0",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ProviderHeaderPresetId = typeof PROVIDER_HEADER_PRESETS[number]["id"];
|
||||
|
||||
/** Unknown IDs are a no-op. A preset changes one UA row and preserves drafts. */
|
||||
export function applyProviderHeaderPreset(rows: readonly ProviderHeader[], id: string): ProviderHeader[] {
|
||||
const preset = PROVIDER_HEADER_PRESETS.find((entry) => entry.id === id);
|
||||
if (!preset) return [...rows];
|
||||
|
||||
let index = rows.findIndex((row) => row.name.trim().toLowerCase() === "user-agent");
|
||||
if (index < 0) index = rows.findIndex((row) => !row.name.trim() && row.value === "");
|
||||
const header: ProviderHeader = { name: preset.name, value: preset.value, source: "static" };
|
||||
if (index < 0) return [...rows, header];
|
||||
return rows.map((row, position) => position === index ? { ...row, ...header } : row);
|
||||
}
|
||||
|
||||
/** Literal examples describe the named header's purpose; environment rows use their own hint. */
|
||||
export function providerHeaderValuePlaceholder(name: string, lang: "zh" | "en"): string {
|
||||
switch (name.trim().toLowerCase()) {
|
||||
case "user-agent":
|
||||
return lang === "zh" ? "例如 claude-cli/2.1.161 (external, cli)" : "e.g. claude-cli/2.1.161 (external, cli)";
|
||||
case "http-referer":
|
||||
return lang === "zh" ? "例如 https://your-app.example" : "e.g. https://your-app.example";
|
||||
case "x-title":
|
||||
case "x-openrouter-title":
|
||||
return lang === "zh" ? "例如 你的应用名称" : "e.g. Your app name";
|
||||
case "authorization":
|
||||
return lang === "zh" ? "例如 Bearer <访问令牌>" : "e.g. Bearer <access token>";
|
||||
case "x-api-key":
|
||||
case "x-goog-api-key":
|
||||
return lang === "zh" ? "例如 供应商提供的 API Key" : "e.g. API key from your provider";
|
||||
default:
|
||||
return lang === "zh" ? "填写该请求头的值" : "Enter this header's value";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ProviderHeaderSource = "static" | "env";
|
||||
|
||||
export type ProviderHeader = {
|
||||
name: string;
|
||||
value: string;
|
||||
source: ProviderHeaderSource;
|
||||
};
|
||||
|
||||
export type ProviderHeaderError = { name?: string; value?: string; source?: string };
|
||||
type Language = "zh" | "en";
|
||||
|
||||
// RFC 9110 field-name tokens. Header values may contain horizontal tabs and
|
||||
// UTF-8 text, matching reqwest's HeaderValue validation on the backend.
|
||||
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
const INVALID_HEADER_VALUE = /[\u0000-\u0008\u000a-\u001f\u007f]/;
|
||||
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export function createProviderHeader(source: ProviderHeaderSource = "static"): ProviderHeader {
|
||||
return { name: "", value: "", source };
|
||||
}
|
||||
|
||||
export function updateProviderHeader(rows: readonly ProviderHeader[], index: number, patch: Partial<ProviderHeader>): ProviderHeader[] {
|
||||
return rows.map((row, position) => position === index ? { ...row, ...patch } : row);
|
||||
}
|
||||
|
||||
export function removeProviderHeader(rows: readonly ProviderHeader[], index: number): ProviderHeader[] {
|
||||
return rows.filter((_, position) => position !== index);
|
||||
}
|
||||
|
||||
export function validateProviderHeaders(rows: readonly ProviderHeader[], lang: Language = "en") {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim().toLowerCase();
|
||||
if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
const errors: ProviderHeaderError[] = rows.map((row) => {
|
||||
const error: ProviderHeaderError = {};
|
||||
const name = row.name.trim();
|
||||
if (!name) error.name = lang === "zh" ? "请填写 Header 名称" : "Enter a header name";
|
||||
else if (!HEADER_NAME.test(name)) error.name = lang === "zh" ? "名称只能使用英文字母、数字和 HTTP 允许的符号" : "Use letters, numbers and valid HTTP header symbols";
|
||||
else if ((counts.get(name.toLowerCase()) ?? 0) > 1) error.name = lang === "zh" ? "此名称重复(不区分大小写),请删除或重命名" : "Duplicate header name (case insensitive). Remove or rename it";
|
||||
if (row.source === "env") {
|
||||
if (!ENV_NAME.test(row.value)) error.value = lang === "zh" ? "请填写环境变量名,如 PROVIDER_PROJECT;仅允许字母、数字和下划线,且不能以数字开头" : "Enter an environment variable name, e.g. PROVIDER_PROJECT, using letters, numbers and underscores, without a leading digit";
|
||||
} else if (row.source === "static") {
|
||||
if (INVALID_HEADER_VALUE.test(row.value)) error.value = lang === "zh" ? "Header 值不能包含换行或控制字符" : "Header values cannot contain line breaks or control characters";
|
||||
} else {
|
||||
error.source = lang === "zh" ? "请选择固定值或环境变量" : "Choose a literal value or environment variable";
|
||||
}
|
||||
return error;
|
||||
});
|
||||
return { errors, valid: errors.every((error) => !error.name && !error.value && !error.source) };
|
||||
}
|
||||
|
||||
/** Normalize only names; changing a value's whitespace can change its meaning. */
|
||||
export function normalizeProviderHeaders(rows: readonly ProviderHeader[]): ProviderHeader[] {
|
||||
if (!validateProviderHeaders(rows).valid) throw new Error("Invalid provider headers");
|
||||
return rows.map((row) => ({ ...row, name: row.name.trim() }));
|
||||
}
|
||||
|
||||
export function findProviderHeader(rows: readonly ProviderHeader[], name: string): ProviderHeader | undefined {
|
||||
const target = name.trim().toLowerCase();
|
||||
return rows.find((row) => row.name.trim().toLowerCase() === target);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ProviderHeader } from "./providerHeaders";
|
||||
|
||||
export type ProviderHeadersControlState = {
|
||||
busy: boolean;
|
||||
reading: boolean;
|
||||
valid: boolean;
|
||||
error: "read" | "write" | null;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
read: (configText: string) => Promise<ProviderHeader[]>;
|
||||
update: (configText: string, headers: ProviderHeader[]) => Promise<string>;
|
||||
getConfigText: () => string;
|
||||
isValid: (rows: readonly ProviderHeader[]) => boolean;
|
||||
normalize: (rows: readonly ProviderHeader[]) => ProviderHeader[];
|
||||
onRowsChange: (rows: ProviderHeader[]) => void;
|
||||
onConfigChange: (configText: string) => void;
|
||||
onStateChange: (state: ProviderHeadersControlState) => void;
|
||||
};
|
||||
|
||||
/** Keep local drafts and asynchronous TOML patches within the current form. */
|
||||
export function createProviderHeadersControl(options: Options) {
|
||||
let disposed = false;
|
||||
let request = 0;
|
||||
let revision = 0;
|
||||
let source: string | null = null;
|
||||
let rows: ProviderHeader[] = [];
|
||||
let dirty = false;
|
||||
let published: { text: string; revision: number } | null = null;
|
||||
|
||||
const emit = (state: ProviderHeadersControlState) => {
|
||||
if (!disposed) options.onStateChange(state);
|
||||
};
|
||||
const current = (id: number, text: string) => !disposed && request === id && options.getConfigText() === text;
|
||||
const idle = (valid: boolean) => emit({ busy: false, reading: false, valid, error: null });
|
||||
|
||||
async function read(text: string) {
|
||||
const id = ++request;
|
||||
emit({ busy: true, reading: true, valid: false, error: null });
|
||||
try {
|
||||
const result = await options.read(text);
|
||||
if (!current(id, text)) return;
|
||||
rows = result.map((row) => ({ ...row }));
|
||||
options.onRowsChange(rows);
|
||||
idle(options.isValid(rows));
|
||||
} catch {
|
||||
// IPC errors can contain configuration snippets. Only expose the failed
|
||||
// operation, never the raw exception or a resolved environment value.
|
||||
if (current(id, text)) emit({ busy: false, reading: false, valid: false, error: "read" });
|
||||
}
|
||||
}
|
||||
|
||||
async function write(text: string) {
|
||||
const id = ++request;
|
||||
const editedRevision = revision;
|
||||
const headers = options.normalize(rows);
|
||||
emit({ busy: true, reading: false, valid: true, error: null });
|
||||
try {
|
||||
const result = await options.update(text, headers);
|
||||
if (!current(id, text)) return;
|
||||
dirty = false;
|
||||
if (result !== text) {
|
||||
published = { text: result, revision: editedRevision };
|
||||
options.onConfigChange(result);
|
||||
}
|
||||
idle(true);
|
||||
} catch {
|
||||
if (current(id, text)) emit({ busy: false, reading: false, valid: false, error: "write" });
|
||||
}
|
||||
}
|
||||
|
||||
async function load(configText: string) {
|
||||
if (disposed || source === configText) return;
|
||||
source = configText;
|
||||
++request;
|
||||
const echo = published;
|
||||
published = null;
|
||||
if (echo?.text === configText && echo.revision === revision && !dirty) {
|
||||
idle(options.isValid(rows));
|
||||
return;
|
||||
}
|
||||
if (dirty) {
|
||||
// Reapply only edited header fields to the latest source, preserving an
|
||||
// unrelated model, endpoint or TOML change. Invalid rows stay editable.
|
||||
if (options.isValid(rows)) await write(configText);
|
||||
else idle(false);
|
||||
return;
|
||||
}
|
||||
if (!configText.trim()) {
|
||||
emit({ busy: false, reading: true, valid: false, error: null });
|
||||
return;
|
||||
}
|
||||
await read(configText);
|
||||
}
|
||||
|
||||
async function changeRows(next: readonly ProviderHeader[]) {
|
||||
if (disposed) return;
|
||||
++request;
|
||||
++revision;
|
||||
rows = next.map((row) => ({ ...row }));
|
||||
dirty = true;
|
||||
options.onRowsChange(rows);
|
||||
if (!options.isValid(rows)) {
|
||||
idle(false);
|
||||
return;
|
||||
}
|
||||
await write(options.getConfigText());
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
if (disposed) return;
|
||||
const text = options.getConfigText();
|
||||
if (dirty && options.isValid(rows)) await write(text);
|
||||
else if (!dirty) await read(text);
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
changeRows,
|
||||
retry,
|
||||
dispose() { disposed = true; ++request; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type UpstreamApi = "responses" | "chat_completions" | "anthropic_messages" | "gemini";
|
||||
|
||||
export function providerUpstreamApi(provider: { upstreamApi?: string | null; wireApi?: string }): string {
|
||||
const configured = provider.upstreamApi?.trim().toLowerCase();
|
||||
if (configured === "chat_completions" || configured === "openai_chat") return "chat_completions";
|
||||
if (configured === "anthropic_messages" || configured === "anthropic") return "anthropic_messages";
|
||||
if (configured === "gemini" || configured === "gemini_native") return "gemini";
|
||||
if (configured === "responses") return "responses";
|
||||
if (configured) return configured;
|
||||
const legacy = provider.wireApi?.trim();
|
||||
if (legacy === "chat" || legacy === "chat_completions") return "chat_completions";
|
||||
return !legacy || legacy === "official" ? "responses" : legacy;
|
||||
}
|
||||
|
||||
export function providerRequiresConversion(provider: { upstreamApi?: string | null; wireApi?: string }): boolean {
|
||||
return providerUpstreamApi(provider) !== "responses";
|
||||
}
|
||||
@@ -2,6 +2,33 @@ type OrderedProviderRow = {
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
type IdentifiedProviderRow = { source: string; id: string };
|
||||
|
||||
export function providerRowKey(row: IdentifiedProviderRow): string {
|
||||
return `${row.source}:${row.id}`;
|
||||
}
|
||||
|
||||
export function applyProviderRowOrder<Row extends IdentifiedProviderRow>(rows: readonly Row[], order: readonly string[]): Row[] {
|
||||
const remaining = new Map(rows.map((row) => [providerRowKey(row), row]));
|
||||
const sorted: Row[] = [];
|
||||
for (const key of order) {
|
||||
const row = remaining.get(key);
|
||||
if (row) {
|
||||
sorted.push(row);
|
||||
remaining.delete(key);
|
||||
}
|
||||
}
|
||||
return [...sorted, ...remaining.values()];
|
||||
}
|
||||
|
||||
export function moveProviderRow(order: readonly string[], source: string, target: string, position: "before" | "after"): string[] {
|
||||
if (source === target || !order.includes(source) || !order.includes(target)) return [...order];
|
||||
const next = order.filter((key) => key !== source);
|
||||
const targetIndex = next.indexOf(target);
|
||||
next.splice(targetIndex + (position === "after" ? 1 : 0), 0, source);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function orderProviderRows<
|
||||
Official extends OrderedProviderRow,
|
||||
Detected extends OrderedProviderRow,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export const SESSION_FOLDER_UNKNOWN_KEY = "__codexx_no_workspace__";
|
||||
const SESSION_PINS_STORAGE_PREFIX = "codexx.sessionPins.v2";
|
||||
|
||||
export type SessionDirectoryIdentities = Readonly<Record<string, string>>;
|
||||
export type SessionPinPreferences = { folders: string[]; sessions: string[] };
|
||||
export type SessionDirectoryGroup<T> = { group: string; folderKey: string; items: T[] };
|
||||
|
||||
/** Only the backend can confirm that differently spelled paths name one directory. */
|
||||
export function sessionDirectoryKey(
|
||||
path?: string | null,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): string {
|
||||
if (path == null || path === "") return SESSION_FOLDER_UNKNOWN_KEY;
|
||||
if (identities && Object.prototype.hasOwnProperty.call(identities, path)) {
|
||||
return identities[path];
|
||||
}
|
||||
// Missing/offline directories keep their exact spelling. Case, backslashes,
|
||||
// roots and spaces can all distinguish legitimate paths on some filesystems.
|
||||
return `path:${path}`;
|
||||
}
|
||||
|
||||
export function sessionPinStorageKey(codexDir: string, verifiedScope?: string | null) {
|
||||
// v1 discarded case and cannot safely assign its pins to a particular home.
|
||||
// A snapshot's file ID alone is not a permanent home identity after deletion.
|
||||
return `${SESSION_PINS_STORAGE_PREFIX}:${encodeURIComponent(verifiedScope || sessionDirectoryKey(codexDir))}`;
|
||||
}
|
||||
|
||||
export function pinnedSessionDirectoryKeys(folders: readonly string[], identities?: SessionDirectoryIdentities): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const stored of folders) {
|
||||
if (stored === SESSION_FOLDER_UNKNOWN_KEY) keys.add(stored);
|
||||
else if (stored.startsWith("path:")) keys.add(sessionDirectoryKey(stored.slice(5), identities));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Persist original paths so offline/recreated folders retain their pins. */
|
||||
export function toggleFolderPins(
|
||||
folders: readonly string[],
|
||||
path?: string | null,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): string[] {
|
||||
const currentKey = sessionDirectoryKey(path, identities);
|
||||
const belongsToCurrentFolder = (stored: string) => pinnedSessionDirectoryKeys([stored], identities).has(currentKey);
|
||||
if (folders.some(belongsToCurrentFolder)) return folders.filter((stored) => !belongsToCurrentFolder(stored));
|
||||
return [...new Set([...folders, sessionDirectoryKey(path)])];
|
||||
}
|
||||
|
||||
export function readSessionPinPreferences(
|
||||
storageKey: string,
|
||||
storage: Pick<Storage, "getItem">,
|
||||
): SessionPinPreferences {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "{}") as Partial<SessionPinPreferences>;
|
||||
return {
|
||||
folders: Array.isArray(parsed.folders) ? parsed.folders.filter((value): value is string => typeof value === "string") : [],
|
||||
sessions: Array.isArray(parsed.sessions) ? parsed.sessions.filter((value): value is string => typeof value === "string") : [],
|
||||
};
|
||||
} catch {
|
||||
return { folders: [], sessions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSessionDirectoryGroups<T extends { cwd?: string | null }>(
|
||||
groups: ReadonlyArray<readonly [string, readonly T[]]>,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): SessionDirectoryGroup<T>[] {
|
||||
const result = new Map<string, SessionDirectoryGroup<T>>();
|
||||
for (const [group, items] of groups) {
|
||||
const folderKey = sessionDirectoryKey(items.find((item) => item.cwd)?.cwd, identities);
|
||||
const existing = result.get(folderKey);
|
||||
if (existing) existing.items.push(...items);
|
||||
else result.set(folderKey, { group, folderKey, items: [...items] });
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
export function sessionsForDirectoryGroup<T>(groups: ReadonlyArray<SessionDirectoryGroup<T>>, key: string): T[] {
|
||||
return groups.find((group) => group.folderKey === key)?.items || [];
|
||||
}
|
||||
@@ -125,3 +125,10 @@
|
||||
@keyframes cx-config-health-enter { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes cx-config-health-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .cx-config-health-toast, .cx-config-health-spinner { animation: none; } }
|
||||
|
||||
.cx-config-health-location { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
|
||||
.cx-config-health-location code,
|
||||
.cx-config-health-key code { color: var(--cx-health-text); font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.cx-config-health-location span { flex-shrink: 0; }
|
||||
.cx-config-health-issues p.cx-config-health-suggestion,
|
||||
.cx-config-health-toast-content p.cx-config-health-suggestion { color: var(--cx-health-text); }
|
||||
|
||||
@@ -603,10 +603,56 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-tree,
|
||||
:root[data-theme="dark"] .cx-session-tree-heading {
|
||||
color: var(--cx-dark-muted);
|
||||
background: var(--cx-dark-surface-raised);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-results {
|
||||
background: var(--cx-dark-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-all,
|
||||
:root[data-theme="dark"] .cx-session-tree-item {
|
||||
color: var(--cx-dark-muted);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-all:hover,
|
||||
:root[data-theme="dark"] .cx-session-tree-item:hover {
|
||||
color: var(--cx-dark-text);
|
||||
background: var(--cx-dark-surface-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-item--active {
|
||||
color: var(--cx-dark-blue);
|
||||
background: var(--cx-dark-blue-surface);
|
||||
border-color: #5879aa;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-item--active .cx-session-folder-copy strong {
|
||||
color: var(--cx-dark-blue);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-group + .cx-session-group {
|
||||
border-top-color: var(--cx-dark-surface-recessed);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-group-heading--selected,
|
||||
:root[data-theme="dark"] .cx-session-group-heading--partial {
|
||||
color: var(--cx-dark-blue);
|
||||
background: var(--cx-dark-blue-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-copy strong {
|
||||
color: var(--cx-dark-text);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-copy small {
|
||||
color: var(--cx-dark-muted);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-row {
|
||||
background: var(--cx-dark-surface);
|
||||
border-color: var(--cx-dark-border);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.cx-provider-headers-control { grid-column: 1 / -1; min-width: 0; }
|
||||
.cx-provider-headers-feedback { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||
.cx-provider-headers-feedback > p { margin: 0; }
|
||||
.cx-provider-headers { grid-column: 1 / -1; min-width: 0; padding: 15px; border: 1px solid var(--cx-provider-border-soft); border-radius: 10px; }
|
||||
.cx-provider-headers-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.cx-provider-headers-heading > div { min-width: 0; }
|
||||
.cx-provider-headers-heading strong { display: flex; align-items: center; gap: 8px; color: var(--cx-provider-text); font-size: 13px; }
|
||||
.cx-provider-headers-heading strong > span { color: var(--cx-provider-muted); font-size: 10px; font-weight: 500; }
|
||||
.cx-provider-headers-heading p, .cx-provider-headers-hint, .cx-provider-headers-empty { margin: 5px 0 0; color: var(--cx-provider-muted); font-size: 11px; line-height: 1.6; }
|
||||
.cx-provider-headers-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.cx-provider-headers-presets { width: 180px; max-width: 100%; min-height: 32px; padding: 0 9px; border: 1px solid var(--cx-provider-border); border-radius: 6px; background: var(--ui-surface); color: var(--cx-provider-text); font-size: 11px; }
|
||||
.cx-provider-headers-presets:focus-visible { outline: 2px solid var(--cx-provider-blue); outline-offset: 2px; }
|
||||
.cx-provider-headers-presets:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.cx-provider-headers .cx-provider-headers-preset-hint { margin: 0 0 12px; }
|
||||
.cx-provider-headers-rows { display: grid; gap: 10px; }
|
||||
.cx-provider-header-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, .8fr) minmax(0, 1.5fr) 32px; gap: 9px; align-items: start; }
|
||||
.cx-provider-header-field { display: grid; min-width: 0; gap: 5px; }
|
||||
.cx-provider-header-field > span { color: var(--cx-provider-muted); font-size: 10px; font-weight: 600; line-height: 1.5; }
|
||||
.cx-provider-header-field > input, .cx-provider-header-field > select { width: 100%; min-width: 0; height: 38px; padding: 0 9px; border: 1px solid var(--cx-provider-border); border-radius: 6px; outline: none; background: var(--ui-surface); color: var(--cx-provider-text); font-size: 12px; }
|
||||
.cx-provider-header-field > input::placeholder { color: var(--cx-provider-muted); opacity: .8; font-size: 11px; }
|
||||
.cx-provider-header-field > input:focus, .cx-provider-header-field > select:focus { border-color: var(--cx-provider-blue); box-shadow: 0 0 0 2px color-mix(in srgb, var(--cx-provider-blue) 12%, transparent); }
|
||||
.cx-provider-header-field > [aria-invalid="true"] { border-color: color-mix(in srgb, var(--ui-danger) 65%, var(--cx-provider-border)); }
|
||||
.cx-provider-header-field > small, .cx-provider-headers-error { color: var(--ui-danger); font-size: 10px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.cx-provider-header-field > :disabled { opacity: .6; cursor: not-allowed; }
|
||||
.cx-provider-header-remove { margin-top: 22px; }
|
||||
.cx-provider-headers-hint, .cx-provider-headers-error { margin: 10px 0 0; }
|
||||
@media (max-width: 900px) {
|
||||
.cx-provider-header-row { grid-template-columns: minmax(0, 1fr) minmax(130px, .8fr) 32px; }
|
||||
.cx-provider-header-field:nth-child(3) { grid-column: 1 / 3; grid-row: 2; }
|
||||
.cx-provider-header-remove { grid-column: 3; grid-row: 1; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.cx-provider-headers { padding: 12px; }
|
||||
.cx-provider-headers-heading { flex-wrap: wrap; }
|
||||
.cx-provider-headers-actions { width: 100%; }
|
||||
.cx-provider-header-row { grid-template-columns: minmax(0, 1fr) 32px; }
|
||||
.cx-provider-header-field, .cx-provider-header-field:nth-child(3) { grid-column: 1; grid-row: auto; }
|
||||
.cx-provider-header-remove { grid-column: 2; }
|
||||
}
|
||||
@@ -200,7 +200,7 @@
|
||||
min-width: 0;
|
||||
min-height: 78px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 18px 42px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
@@ -225,9 +225,23 @@
|
||||
/* Give official account emails their own centered column. Current-state
|
||||
badges can change the actions without moving the email in any row. */
|
||||
.cx-providers-row--official {
|
||||
grid-template-columns: 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
|
||||
grid-template-columns: 18px 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
|
||||
}
|
||||
|
||||
.cx-providers-sort-hint { margin: -7px 1px 12px; color: var(--cx-provider-muted); font-size: 11px; line-height: 1.5; }
|
||||
.cx-providers-drag-handle { display: grid; place-items: center; width: 22px; height: 34px; margin-left: -2px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--cx-provider-muted); cursor: grab; touch-action: none; user-select: none; }
|
||||
.cx-providers-drag-handle:hover:not(:disabled) { background: var(--cx-provider-blue-soft); color: var(--cx-provider-blue); }
|
||||
.cx-providers-drag-handle:active { cursor: grabbing; }
|
||||
.cx-providers-drag-handle:focus-visible { outline: 2px solid var(--cx-provider-blue); outline-offset: 2px; }
|
||||
.cx-providers-drag-handle:disabled { opacity: .35; cursor: default; }
|
||||
.cx-providers-row--dragging { opacity: .45; }
|
||||
.cx-providers-row--drop-before { box-shadow: inset 0 3px 0 var(--cx-provider-blue); }
|
||||
.cx-providers-row--drop-after { box-shadow: inset 0 -3px 0 var(--cx-provider-blue); }
|
||||
.cx-providers-row-model { display: flex; align-items: baseline; gap: 6px; margin-top: 5px; min-width: 0; font-size: 11px; line-height: 1.5; }
|
||||
.cx-providers-row-model>span { flex-shrink: 0; color: var(--cx-provider-muted); }
|
||||
.cx-providers-row-model>b { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cx-provider-text); }
|
||||
.cx-providers-row-model>code { margin: 0; flex: 0 1 auto; }
|
||||
|
||||
.cx-providers-row--official > .cx-providers-login-badge {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -915,17 +929,17 @@
|
||||
@media (max-width: 760px) {
|
||||
.cx-providers-row,
|
||||
.cx-providers-row--official {
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
grid-template-columns: 18px 40px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-providers-row--official > .cx-providers-login-badge {
|
||||
grid-column: 2;
|
||||
grid-column: 3;
|
||||
width: min(300px, 100%);
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.cx-providers-row-actions {
|
||||
grid-column: 2;
|
||||
grid-column: 3;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@@ -960,3 +974,10 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cx-providers-protocol-hint {
|
||||
margin: 0;
|
||||
color: var(--cx-provider-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -387,6 +387,146 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-session-browser {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
flex: 1 1 0;
|
||||
display: grid;
|
||||
grid-template-columns: 238px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cx-session-browser--flat {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-folder-tree {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #e6e9ed;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.cx-session-tree-heading {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 11px;
|
||||
border-bottom: 1px solid #e6e9ed;
|
||||
color: #767e89;
|
||||
background: #fafbfc;
|
||||
font-size: 10px;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cx-session-tree-items {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
padding: 7px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.cx-session-tree-all,
|
||||
.cx-session-tree-item {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
color: #626a75;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cx-session-tree-all {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 17px minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 690;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-tree-all em,
|
||||
.cx-session-tree-select em {
|
||||
color: #969da7;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-session-tree-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 27px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cx-session-tree-select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 17px minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 4px 3px 4px 8px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-tree-item > .cx-session-pin-action {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cx-session-tree-all:hover,
|
||||
.cx-session-tree-item:hover {
|
||||
color: #354052;
|
||||
background: #f0f3f7;
|
||||
}
|
||||
|
||||
.cx-session-tree-item--active {
|
||||
border-color: #cfdfff;
|
||||
color: #2454b3;
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.cx-session-tree-item--active .cx-session-folder-copy strong {
|
||||
color: #2454b3;
|
||||
}
|
||||
|
||||
.cx-session-tree-all:focus-visible,
|
||||
.cx-session-tree-select:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cx-session-results {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.cx-session-scroll {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -426,7 +566,7 @@
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(200px, 1fr) 104px 104px 112px 104px 34px;
|
||||
grid-template-columns: 26px minmax(200px, 1fr) 104px 104px 112px 104px 70px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -456,9 +596,16 @@
|
||||
}
|
||||
|
||||
.cx-session-table-body {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cx-session-virtual-item {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.cx-session-group {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -470,17 +617,20 @@
|
||||
.cx-session-group-heading {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto 30px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid #eef0f2;
|
||||
color: #737b86;
|
||||
background: #fcfcfd;
|
||||
font-size: 11px;
|
||||
font-weight: 680;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-group-heading--separated {
|
||||
border-top: 7px solid #f7f8f9;
|
||||
}
|
||||
|
||||
.cx-session-group-heading--selected,
|
||||
@@ -489,13 +639,61 @@
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.cx-session-group-heading > span {
|
||||
.cx-session-group-heading--pinned {
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 72%, transparent);
|
||||
}
|
||||
|
||||
.cx-session-folder-toggle {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-folder-toggle > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy strong,
|
||||
.cx-session-folder-copy small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy strong {
|
||||
color: #3f4650;
|
||||
font-size: 11.5px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.cx-session-folder-copy small {
|
||||
color: #9299a3;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cx-session-folder-toggle:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cx-session-group-heading em {
|
||||
color: #969ca5;
|
||||
font-size: 10px;
|
||||
@@ -534,6 +732,14 @@
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.cx-session-row--pinned:not(.cx-session-row--selected) {
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 52%, transparent);
|
||||
}
|
||||
|
||||
.cx-session-row--nested .cx-session-row-copy {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.cx-session-row:focus-within {
|
||||
outline: 1px solid rgba(37, 99, 235, 0.38);
|
||||
outline-offset: -1px;
|
||||
@@ -664,7 +870,15 @@
|
||||
color: #4669a8;
|
||||
}
|
||||
|
||||
.cx-session-row-export {
|
||||
.cx-session-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cx-session-row-export,
|
||||
.cx-session-pin-action {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
justify-self: end;
|
||||
@@ -678,22 +892,44 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-session-row-export:hover:not(:disabled) {
|
||||
.cx-session-row-export:hover:not(:disabled),
|
||||
.cx-session-pin-action:hover:not(:disabled) {
|
||||
color: var(--ui-accent);
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.cx-session-row-export:focus-visible {
|
||||
.cx-session-row-export:focus-visible,
|
||||
.cx-session-pin-action:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cx-session-row-export:disabled {
|
||||
.cx-session-row-export:disabled,
|
||||
.cx-session-pin-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cx-session-pin-action--active {
|
||||
color: var(--ui-accent);
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 42%, var(--ui-border));
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.cx-session-load-more {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #eef0f2;
|
||||
color: #7e8793;
|
||||
background: #fafbfc;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cx-session-empty {
|
||||
min-height: 120px;
|
||||
flex: 1 1 auto;
|
||||
@@ -911,9 +1147,17 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.cx-session-browser {
|
||||
grid-template-columns: 210px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-browser--flat {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
grid-template-columns: 26px minmax(180px, 1fr) 96px 96px 104px 34px;
|
||||
grid-template-columns: 26px minmax(180px, 1fr) 96px 96px 104px 70px;
|
||||
}
|
||||
|
||||
.cx-session-id-heading,
|
||||
@@ -957,9 +1201,23 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cx-session-browser {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(110px, 30%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-browser--flat {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-session-folder-tree {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e6e9ed;
|
||||
}
|
||||
|
||||
.cx-session-column-head,
|
||||
.cx-session-row {
|
||||
grid-template-columns: 26px minmax(0, 1fr) 82px 34px;
|
||||
grid-template-columns: 26px minmax(0, 1fr) 82px 70px;
|
||||
}
|
||||
|
||||
.cx-session-column-head span:nth-child(n + 4):not(.cx-session-actions-heading),
|
||||
|
||||
@@ -36,6 +36,7 @@ export type SavedProvider = {
|
||||
apiKey?: string;
|
||||
tomlConfig?: string;
|
||||
wireApi: string;
|
||||
upstreamApi?: string | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
modelMappings?: ProviderModelMapping[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AppUpdateInfo, ReleaseInfo } from "./types";
|
||||
|
||||
/** Release metadata can identify a version, but does not authorize installation.
|
||||
* An installed edition keeps its native check/retry controls after a failed
|
||||
* signed-channel check; only editions without a native updater use download-only.
|
||||
*/
|
||||
export function releaseInfoFromFallback(update: AppUpdateInfo, nativeCheckAttempted: boolean): ReleaseInfo {
|
||||
return {
|
||||
status: nativeCheckAttempted && !update.hasUpdate ? "error" : "ok",
|
||||
latestVersion: update.latestVersion,
|
||||
htmlUrl: update.htmlUrl,
|
||||
hasUpdate: update.hasUpdate,
|
||||
updateMethod: nativeCheckAttempted ? "native" : update.hasUpdate ? "download" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseInfoFromCheckFailure(nativeCheckAttempted: boolean): ReleaseInfo {
|
||||
return { status: "error", updateMethod: nativeCheckAttempted ? "native" : undefined };
|
||||
}
|
||||
@@ -155,14 +155,17 @@ export function useConfigHealth(options: {
|
||||
dismiss(); accept(result.report);
|
||||
if (result.report.status === "healthy") registry.current?.clear(result.report);
|
||||
const zh = runtime.current.lang === "zh";
|
||||
const remaining = result.report.issues[0];
|
||||
runtime.current.onHint(result.report.status === "healthy"
|
||||
? (zh ? "配置已检查并修复,请重新打开原来的 Codex 对话。" : "Configuration checked and repaired. Reopen the Codex conversation.")
|
||||
: (zh ? "可自动处理的部分已修复,其余问题可在通用设置中查看。" : "Supported repairs are complete. Review the remaining issues in General settings."));
|
||||
: remaining
|
||||
? `${zh ? "已完成安全修复;仍需处理:" : "Supported repairs are complete; review: "}${remaining.path || `${result.report.codexDir}/config.toml`}${remaining.line ? `:${remaining.line}:${remaining.column || 1}` : ""} · ${remaining.key || remaining.title}。${remaining.suggestion || remaining.description}`
|
||||
: (zh ? "请在通用设置中查看检查结果。" : "Review the check result in General settings."));
|
||||
runtime.current.onRepaired();
|
||||
} catch (cause) {
|
||||
if (current()) {
|
||||
dismiss(); setError(String(cause));
|
||||
runtime.current.onHint(runtime.current.lang === "zh" ? "配置未修复,请到「设置 → 通用设置」查看原因或重新检查。" : "Configuration was not repaired. Review the issue or check again in Settings → General.");
|
||||
runtime.current.onHint(`${runtime.current.lang === "zh" ? "配置未修复:" : "Configuration was not repaired: "}${String(cause)}`);
|
||||
}
|
||||
} finally { if (current()) { busyRef.current = false; setRepairing(false); } }
|
||||
}, [accept, dismiss]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { AppUpdaterController, isAppUpdateBusy, UPDATER_SLOW_NOTICE_MS } from "../src/appUpdaterController.ts";
|
||||
import { AppUpdaterController, appUpdaterCheckFailureMessage, classifyAppUpdaterCheckError, isAppUpdateBusy, UPDATER_SLOW_NOTICE_MS } from "../src/appUpdaterController.ts";
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
@@ -198,3 +198,130 @@ test("a pending forced check cannot install its stale update resource", async ()
|
||||
await check;
|
||||
assert.equal(f.counts.closes, 1);
|
||||
});
|
||||
|
||||
|
||||
const checkFailures = [
|
||||
["timeout", new Error("error sending request: operation timed out at https://private.fixture.test/update?token=private-secret")],
|
||||
["network", { code: "NetworkError", message: "connection refused; Authorization: Bearer private-secret at https://private.fixture.test" }],
|
||||
["platform", "the platform `windows-x86_64` was not found in the response `platforms` object"],
|
||||
["platform", new Error("Unsupported application architecture, expected x86 or aarch64")],
|
||||
["invalid-release", "Could not fetch a valid release JSON from the remote"],
|
||||
["invalid-release", { code: "Serialization", message: "missing field `signature` at line 1 column 1: private-secret" }],
|
||||
["public-key", "Could not decode public key or signature: private-secret"],
|
||||
["unknown", { message: "Something unexpected happened: private-secret", url: "https://private.fixture.test", authorization: "Bearer private-secret" }],
|
||||
];
|
||||
|
||||
test("check failures are classified into fixed safe explanations in both languages", () => {
|
||||
for (const [kind, cause] of checkFailures) {
|
||||
assert.equal(classifyAppUpdaterCheckError(cause), kind);
|
||||
for (const lang of ["zh", "en"]) {
|
||||
const message = appUpdaterCheckFailureMessage(kind, lang);
|
||||
assert.ok(message.length > 20);
|
||||
assert.ok(!message.includes("private-secret"));
|
||||
assert.ok(!message.includes("https://"));
|
||||
assert.ok(!message.includes("Authorization"));
|
||||
}
|
||||
}
|
||||
for (const cause of [null, undefined, 123, false, {}, { message: {} }]) assert.equal(classifyAppUpdaterCheckError(cause), "unknown");
|
||||
assert.equal(classifyAppUpdaterCheckError("Unrecognized error https://fixture.test/timeout?publickey=secret"), "unknown", "URL text alone must not invent the reason");
|
||||
});
|
||||
|
||||
test("SDK check rejection retains a safe cause without any raw endpoint or credentials", async () => {
|
||||
for (const [kind, cause] of checkFailures) {
|
||||
const f = fixture();
|
||||
const rejected = deferred();
|
||||
f.setCheckOperation(rejected);
|
||||
const job = f.controller.check();
|
||||
rejected.reject(cause);
|
||||
assert.equal(await job, "error");
|
||||
const state = f.controller.getSnapshot();
|
||||
assert.equal(state.phase, "error");
|
||||
assert.equal(state.failure, "check");
|
||||
assert.equal(state.checkFailure, kind);
|
||||
assert.equal(state.errorMessage, appUpdaterCheckFailureMessage(kind));
|
||||
const visible = JSON.stringify(state);
|
||||
for (const secret of ["private-secret", "private.fixture.test", "Authorization", "Bearer"]) assert.ok(!visible.includes(secret));
|
||||
assert.equal(f.counts.installs, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed forced checks release their prior RID and clear stale release details", async () => {
|
||||
const f = fixture();
|
||||
f.setUpdate({ rid: 21, currentVersion: "0.3.21", version: "0.3.24", body: "Old release notes", date: "2026-10-01", close: async () => { f.counts.closes++; } });
|
||||
await f.controller.check();
|
||||
assert.equal(f.controller.getSnapshot().latestVersion, "0.3.24");
|
||||
const failed = deferred();
|
||||
f.setCheckOperation(failed);
|
||||
const job = f.controller.check({ force: true });
|
||||
assert.equal(f.controller.getSnapshot().latestVersion, null);
|
||||
assert.equal(f.controller.getSnapshot().notes, null);
|
||||
failed.reject(new Error("NetworkError: connection refused"));
|
||||
await job;
|
||||
assert.equal(f.counts.closes, 1);
|
||||
assert.equal(f.controller.getSnapshot().failure, "check");
|
||||
assert.equal(f.controller.getSnapshot().checkFailure, "network");
|
||||
for (const field of ["latestVersion", "notes", "publishedAt"]) assert.equal(f.controller.getSnapshot()[field], null);
|
||||
assert.equal(await f.controller.downloadAndInstall(), "error");
|
||||
assert.equal(f.counts.installs, 0, "failed SDK metadata cannot authorize an installer");
|
||||
});
|
||||
|
||||
test("retry checks again and installation requires a newly obtained legal SDK RID", async () => {
|
||||
const f = fixture();
|
||||
const failed = deferred();
|
||||
f.setCheckOperation(failed);
|
||||
const first = f.controller.check();
|
||||
failed.reject(new Error("operation timed out"));
|
||||
await first;
|
||||
const pending = deferred();
|
||||
f.setCheckOperation(pending);
|
||||
const retry = f.controller.retry();
|
||||
assert.equal(f.counts.checks, 2);
|
||||
assert.equal(f.controller.getSnapshot().phase, "checking");
|
||||
assert.equal(await f.controller.downloadAndInstall(), "checking");
|
||||
assert.equal(f.counts.installs, 0);
|
||||
pending.resolve({ rid: 24, currentVersion: "0.3.21", version: "0.3.24", body: "New release", close: async () => { f.counts.closes++; } });
|
||||
assert.equal(await retry, "available");
|
||||
assert.equal(f.controller.getSnapshot().errorMessage, null);
|
||||
assert.equal(f.controller.getSnapshot().checkFailure, null);
|
||||
assert.equal(f.counts.installs, 0, "retrying the check must not automatically install");
|
||||
const installing = f.controller.downloadAndInstall();
|
||||
assert.equal(f.operation.value.rid, 24);
|
||||
assert.equal(f.counts.installs, 1);
|
||||
f.operation.resolve({ restartRequired: true });
|
||||
assert.equal(await installing, "ready");
|
||||
});
|
||||
|
||||
test("invalid SDK resource IDs are closed and can never reach installation", async () => {
|
||||
for (const rid of [-1, NaN, Infinity, 1.5, 0x1_0000_0000, "24", undefined]) {
|
||||
const f = fixture();
|
||||
f.setUpdate({ rid, currentVersion: "0.3.21", version: "0.3.24", close: async () => { f.counts.closes++; } });
|
||||
assert.equal(await f.controller.check(), "error", String(rid));
|
||||
assert.equal(f.controller.getSnapshot().checkFailure, "invalid-release");
|
||||
assert.equal(f.counts.closes, 1);
|
||||
assert.equal(await f.controller.downloadAndInstall(), "error");
|
||||
assert.equal(f.counts.installs, 0);
|
||||
}
|
||||
const f = fixture();
|
||||
f.setUpdate({ rid: 0, currentVersion: "0.3.21", version: "0.3.24", close: async () => { f.counts.closes++; } });
|
||||
assert.equal(await f.controller.check(), "available", "Tauri resource IDs may start at zero");
|
||||
});
|
||||
|
||||
|
||||
test("automatic transport recovery starts a fresh byte count and installs only once", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.check();
|
||||
const job = f.controller.downloadAndInstall();
|
||||
f.operation.onEvent({ event: "Started", data: { contentLength: 100 } });
|
||||
f.operation.onEvent({ event: "Progress", data: { chunkLength: 45 } });
|
||||
assert.equal(f.controller.getSnapshot().downloadedBytes, 45);
|
||||
f.operation.onEvent({ event: "Started", data: { contentLength: 100 } });
|
||||
assert.equal(f.controller.getSnapshot().downloadedBytes, 0);
|
||||
f.operation.onEvent({ event: "Progress", data: { chunkLength: 100 } });
|
||||
f.operation.onEvent({ event: "Verifying" });
|
||||
f.operation.onEvent({ event: "Preparing" });
|
||||
f.operation.onEvent({ event: "HandedOff" });
|
||||
f.operation.resolve({ restartRequired: false });
|
||||
assert.equal(await job, "handed-off");
|
||||
assert.equal(f.counts.installs, 1);
|
||||
assert.equal(f.counts.checks, 1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { configHealthIssueLocation, primaryConfigHealthIssue, configHealthRepairUnavailableReason } from "../src/configHealthPresentation.ts";
|
||||
import { configHealthIssueKey } from "../src/configHealthMonitor.ts";
|
||||
|
||||
const desktop = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const issue = {
|
||||
code: "provider-boolean-text", title: "供应商开关格式不正确", description: "supports_websockets 被写成了字符串。",
|
||||
repairable: true, path: "/fixtures/配置目录/config.toml", line: 7, column: 3,
|
||||
key: "model_providers.custom.supports_websockets", suggestion: "将 supports_websockets 改为 false(不带引号)。",
|
||||
};
|
||||
const report = {
|
||||
codexDir: "/fixtures/配置目录", fingerprint: "fixture-only", status: "issues", issues: [issue],
|
||||
canRepair: true, repairSummary: ["将写成文字的开关恢复为正确格式"], checkedAt: "2026-10-08T00:00:00Z",
|
||||
};
|
||||
|
||||
let components;
|
||||
async function ui() {
|
||||
if (components) return components;
|
||||
components = (async () => {
|
||||
const scratch = await mkdtemp(join(desktop, "tests/.config-health-ui-"));
|
||||
try {
|
||||
const emitted = new Map();
|
||||
async function compile(sourcePath) {
|
||||
if (emitted.has(sourcePath)) return emitted.get(sourcePath);
|
||||
const outputPath = join(scratch, relative(desktop, sourcePath).replace(/\.tsx?$/, ".mjs"));
|
||||
emitted.set(sourcePath, outputPath);
|
||||
const source = await readFile(sourcePath, "utf8");
|
||||
let output = ts.transpileModule(source, {
|
||||
fileName: sourcePath,
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText.replace(/import\s*["'][^"']+\.css["'];?/g, "");
|
||||
for (const match of [...output.matchAll(/\bfrom\s*["'](\.[^"']+)["']/g)]) {
|
||||
const specifier = match[1];
|
||||
const base = resolve(dirname(sourcePath), specifier);
|
||||
let dependency;
|
||||
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts")]) {
|
||||
try { await readFile(candidate, "utf8"); dependency = candidate; break; } catch { /* Try normal TS module resolution candidates. */ }
|
||||
}
|
||||
assert.ok(dependency, `could not resolve ${specifier} from ${sourcePath}`);
|
||||
const dependencyOutput = await compile(dependency);
|
||||
let replacement = relative(dirname(outputPath), dependencyOutput).replaceAll("\\", "/");
|
||||
if (!replacement.startsWith(".")) replacement = `./${replacement}`;
|
||||
output = output.replaceAll(`"${specifier}"`, `"${replacement}"`).replaceAll(`'${specifier}'`, `'${replacement}'`);
|
||||
}
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, output);
|
||||
return outputPath;
|
||||
}
|
||||
const panel = await compile(join(desktop, "src/components/ConfigHealthPanel.tsx"));
|
||||
const toast = await compile(join(desktop, "src/components/ConfigHealthToast.tsx"));
|
||||
return { ...await import(pathToFileURL(panel).href), ...await import(pathToFileURL(toast).href) };
|
||||
} finally { await rm(scratch, { recursive: true, force: true }); }
|
||||
})();
|
||||
return components;
|
||||
}
|
||||
|
||||
const panelProps = { lang: "zh", report, checking: false, repairing: false, error: "", onCheck() {}, onRepair() {}, onOpenConfig() {} };
|
||||
const toastProps = { lang: "zh", report, repairing: false, onRepair() {}, onDismiss() {}, onOpenSettings() {} };
|
||||
|
||||
test("exact path, original line and column, parameter, and suggestion render in panel and notice", async () => {
|
||||
const { ConfigHealthPanel, ConfigHealthToast } = await ui();
|
||||
for (const [Component, props] of [[ConfigHealthPanel, panelProps], [ConfigHealthToast, toastProps]]) {
|
||||
const html = renderToStaticMarkup(createElement(Component, props));
|
||||
for (const detail of [issue.path, "第 7 行,第 3 列", issue.key, issue.suggestion, "修复配置"]) assert.ok(html.includes(detail), detail);
|
||||
const repairButton = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(([button]) => button).find((button) => button.includes("修复配置"));
|
||||
assert.ok(repairButton);
|
||||
assert.doesNotMatch(repairButton, /\bdisabled=/);
|
||||
}
|
||||
assert.equal(configHealthIssueLocation(issue, report, "en").position, "line 7, column 3");
|
||||
});
|
||||
|
||||
test("official history repair is actionable and a missing table never claims an invented source line", async () => {
|
||||
const missing = { ...issue, code: "session-provider-definition-missing", title: "旧会话使用的供应商缺少配置", line: null, column: null, key: "model_providers.old_proxy", suggestion: "新增 model_providers.old_proxy,使用当前已确认的 OpenAI 官方登录。" };
|
||||
const official = { ...report, issues: [missing], repairSummary: ["让旧会话沿用当前供应商「OpenAI」"] };
|
||||
const { ConfigHealthPanel, ConfigHealthToast } = await ui();
|
||||
for (const [Component, props] of [[ConfigHealthPanel, panelProps], [ConfigHealthToast, toastProps]]) {
|
||||
const html = renderToStaticMarkup(createElement(Component, { ...props, report: official }));
|
||||
assert.ok(html.includes("修复配置"));
|
||||
assert.ok(html.includes("未定义,尚无对应行"));
|
||||
assert.ok(html.includes("model_providers.old_proxy"));
|
||||
assert.ok(html.includes("OpenAI"));
|
||||
assert.ok(!html.includes("第 7 行"));
|
||||
}
|
||||
});
|
||||
|
||||
test("unsupported values display their precise manual action and explain the unavailable repair button", async () => {
|
||||
const unsupported = { ...issue, repairable: false, key: "model_providers.custom.wire_api", suggestion: "先向供应商确认 Responses API 支持,再设置 wire_api = responses。" };
|
||||
const manual = { ...report, canRepair: false, repairSummary: [], issues: [unsupported] };
|
||||
const { ConfigHealthPanel, ConfigHealthToast } = await ui();
|
||||
const html = renderToStaticMarkup(createElement(ConfigHealthPanel, { ...panelProps, report: manual }));
|
||||
assert.ok(html.includes("需手动修复"));
|
||||
assert.ok(html.includes(unsupported.suggestion));
|
||||
assert.ok(html.includes(`title="${unsupported.suggestion}"`));
|
||||
const manualButton = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(([button]) => button).find((button) => button.includes("需手动修复"));
|
||||
assert.ok(manualButton);
|
||||
assert.match(manualButton, /\bdisabled=""/);
|
||||
const toast = renderToStaticMarkup(createElement(ConfigHealthToast, { ...toastProps, report: manual }));
|
||||
assert.ok(toast.includes(unsupported.key));
|
||||
assert.ok(toast.includes(unsupported.suggestion));
|
||||
assert.ok(toast.includes("查看设置"));
|
||||
assert.ok(!toast.includes("修复配置"));
|
||||
});
|
||||
|
||||
test("a mixed report advertises a supported issue while the panel retains all manual diagnostics", async () => {
|
||||
const manual = { ...issue, repairable: false, title: "必须手动处理", key: "model_providers.custom.base_url" };
|
||||
const mixed = { ...report, issues: [manual, issue] };
|
||||
assert.equal(primaryConfigHealthIssue(mixed), issue);
|
||||
assert.equal(configHealthRepairUnavailableReason({ ...mixed, canRepair: false }, "zh"), manual.suggestion);
|
||||
const { ConfigHealthToast, ConfigHealthPanel } = await ui();
|
||||
const toast = renderToStaticMarkup(createElement(ConfigHealthToast, { ...toastProps, report: mixed }));
|
||||
assert.ok(toast.includes(issue.key));
|
||||
assert.ok(!toast.includes(manual.key));
|
||||
const panel = renderToStaticMarkup(createElement(ConfigHealthPanel, { ...panelProps, report: mixed }));
|
||||
assert.ok(panel.includes(manual.key));
|
||||
assert.ok(panel.includes(issue.key));
|
||||
});
|
||||
|
||||
test("notification identity follows the parameter and repairability while ignoring source line shifts", () => {
|
||||
assert.equal(configHealthIssueKey(report), configHealthIssueKey({ ...report, issues: [{ ...issue, line: 80, column: 5 }] }));
|
||||
assert.notEqual(configHealthIssueKey(report), configHealthIssueKey({ ...report, issues: [{ ...issue, key: "model_providers.other.supports_websockets" }] }));
|
||||
assert.notEqual(configHealthIssueKey(report), configHealthIssueKey({ ...report, issues: [{ ...issue, repairable: false }] }));
|
||||
});
|
||||
|
||||
test("file-level diagnostics and legacy reports use a usable path without claiming a line", () => {
|
||||
assert.deepEqual(configHealthIssueLocation({ ...issue, key: '"<文件编码>"', line: null, column: null }, report, "zh"), { path: issue.path, position: "文件层面", key: '"<文件编码>"' });
|
||||
const legacy = { code: "legacy", title: "Legacy", description: "Review", repairable: false };
|
||||
assert.equal(configHealthIssueLocation(legacy, { ...report, codexDir: "/fixtures/codex/" }, "en").path, "/fixtures/codex/config.toml");
|
||||
});
|
||||
+43
@@ -199,6 +199,49 @@ invalid context sizes disable Save. Reopening a saved item retains its mappings;
|
||||
enabling it shows the restart notice. Actual catalog files, outbound model IDs,
|
||||
authentication and rollback are covered by the Rust and native Codex checks.
|
||||
|
||||
## Provider card model names and drag ordering (#61 / #62)
|
||||
|
||||
1. Reset the fixture. The initial **Fixture CC Switch** model is
|
||||
`fixture-model-a`, **Fixture Saved Provider** uses `fixture-model-b`, and the
|
||||
default official profile uses `fixture-official-model`. The provider cards
|
||||
should show these model names, including the current detected provider.
|
||||
2. Copy a saved provider twice and copy an official profile once to create
|
||||
several reorderable rows. Copies keep their selected model; editing a copy's
|
||||
selected model must update its own card when returning to the list.
|
||||
3. Drag the card handles up and down, including moving the default official
|
||||
profile. Check the new visual order, leave the provider page, return, and
|
||||
reload. The order must survive. Enabling another row must keep the order and
|
||||
move only the current-provider indicator. Copying or deleting a row must keep
|
||||
the relative order of the remaining rows.
|
||||
4. Set **Fixture:排序保存** to **保存失败** and drag a card. The list should
|
||||
return to its previous saved order and show an error. Diagnostics must keep
|
||||
the original `providerOrders` value and no provider switch may occur.
|
||||
5. Select **延迟成功**, drag a card, and inspect `pendingProviderOrder: 1`.
|
||||
Use **Fixture:完成排序保存** to settle it. Select **延迟失败** and repeat:
|
||||
settling must reject the request and preserve the original saved order.
|
||||
**Fixture:使排序保存失败** rejects any pending order save explicitly.
|
||||
Changing the selector while a request is pending does not change its outcome.
|
||||
6. Check the handles and model labels in both themes, with long provider/model
|
||||
names and keyboard focus. When drag ordering is unavailable during another
|
||||
provider action, the controls should communicate that state.
|
||||
|
||||
The fixture implements `get_provider_order({ configDir }) -> string[]` and
|
||||
`save_provider_order({ configDir, order }) -> string[]`. Keys use
|
||||
`official:<id>`, `local:<id>`, and `detected:<id>`. Duplicate or malformed keys
|
||||
reject without mutation; valid stale keys are kept for the renderer to ignore.
|
||||
Ordering is stored by the normalized virtual config directory and persisted in
|
||||
`codexx.fixture.backend.v2` alongside providers. It does not reorder the backing
|
||||
provider arrays or affect failover priority. The virtual scope normalizer handles
|
||||
simple slash paths, `.` and `..`; filesystem aliases, migration, and atomic SQL
|
||||
writes are covered by the Rust tests. Other fixture data still models one shared
|
||||
configuration directory.
|
||||
|
||||
Browser assertions may use `window.__CODEX_X_FIXTURE__.setProviderOrderMode(mode)`,
|
||||
`.completeProviderOrder()`, `.failProviderOrder()`, and
|
||||
`.getProviderOrder(configDir)`. Diagnostics expose `providerOrders`,
|
||||
`providerOrderMode`, and `pendingProviderOrder`. Modes reset to `normal` on reload;
|
||||
saved order persists. Restart the fixture server after changing `ui-runtime.js`.
|
||||
|
||||
## Provider presets and retained usage results
|
||||
|
||||
The add-provider form exposes eight preset cards. Cards show only brand marks and names; switching among all presets, including official sign-in, keeps the same add-page transition key. The initial custom TOML includes a reasoning setting, a fixture MCP server and a project entry for inheritance checks. Selecting a vendor fills its
|
||||
|
||||
+81
-1
@@ -9,7 +9,8 @@
|
||||
const counts = {};
|
||||
const switches = [];
|
||||
const commands = [];
|
||||
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [] };
|
||||
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [], providerOrder: [] };
|
||||
const providerOrders = new Map();
|
||||
const callbacks = new Map();
|
||||
const eventListeners = new Map();
|
||||
let nextCallback = 1;
|
||||
@@ -35,6 +36,7 @@
|
||||
const resetRoutingHealth = new Set();
|
||||
let failoverMode = "normal";
|
||||
let providerBaseReadFailure = false;
|
||||
let providerOrderMode = "normal";
|
||||
const updaterEnabled = new URLSearchParams(location.search).has("updater");
|
||||
let updateRequest = null;
|
||||
function updateStage(event) { updateRequest?.onEvent.onmessage(event); }
|
||||
@@ -232,6 +234,11 @@
|
||||
state = saved.state;
|
||||
if (saved.failoverSettings?.version === 2) failoverSettings = { ...defaultRoutingSettings(), ...saved.failoverSettings };
|
||||
savedProviders = saved.savedProviders;
|
||||
for (const [scope, order] of Object.entries(saved.providerOrders || {})) {
|
||||
if (Array.isArray(order) && order.every((key) => typeof key === "string")) {
|
||||
providerOrders.set(scope, clone(order));
|
||||
}
|
||||
}
|
||||
savedPrompts = saved.savedPrompts;
|
||||
nextOfficialId = saved.nextOfficialId;
|
||||
officialProfiles.clear();
|
||||
@@ -247,6 +254,7 @@
|
||||
version: 2,
|
||||
state,
|
||||
savedProviders,
|
||||
providerOrders: Object.fromEntries(providerOrders),
|
||||
savedPrompts,
|
||||
nextOfficialId,
|
||||
failoverSettings,
|
||||
@@ -290,6 +298,9 @@
|
||||
usageMode,
|
||||
pendingUsage: pending.usage.length,
|
||||
savedProviderIds: savedProviders.map((provider) => provider.id),
|
||||
providerOrders: Object.fromEntries(providerOrders),
|
||||
providerOrderMode,
|
||||
pendingProviderOrder: pending.providerOrder.length,
|
||||
commands: clone(commands),
|
||||
};
|
||||
}
|
||||
@@ -375,6 +386,52 @@
|
||||
return next;
|
||||
}
|
||||
|
||||
function providerOrderScope(configDir) {
|
||||
// Virtual fixture paths have no filesystem or symlinks. Normalize simple
|
||||
// absolute paths only; Rust tests cover real canonical paths and aliases.
|
||||
const raw = typeof configDir === "string" && configDir.trim() ? configDir.trim() : codexDir;
|
||||
const parts = [];
|
||||
for (const part of raw.replaceAll("\\", "/").split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") parts.pop();
|
||||
else parts.push(part);
|
||||
}
|
||||
return `/${parts.join("/")}`;
|
||||
}
|
||||
|
||||
function getProviderOrder(configDir) {
|
||||
return clone(providerOrders.get(providerOrderScope(configDir)) || []);
|
||||
}
|
||||
|
||||
function setProviderOrderMode(mode) {
|
||||
if (!["normal", "error", "pending", "pending-error"].includes(mode)) throw new Error("Unknown fixture provider-order mode");
|
||||
providerOrderMode = mode;
|
||||
render();
|
||||
}
|
||||
|
||||
function saveProviderOrder({ configDir, order }) {
|
||||
if (!Array.isArray(order) || order.some((key) => {
|
||||
if (typeof key !== "string" || /[\u0000-\u001f\u007f-\u009f]/.test(key)) return true;
|
||||
const separator = key.indexOf(":");
|
||||
const source = key.slice(0, separator);
|
||||
const id = key.slice(separator + 1);
|
||||
return !["official", "local", "detected"].includes(source) || !id || id !== id.trim();
|
||||
}) || new Set(order).size !== order.length) throw new Error("Fixture:排序包含无效或重复供应商");
|
||||
const scope = providerOrderScope(configDir);
|
||||
const next = clone(order);
|
||||
const mode = providerOrderMode;
|
||||
const failure = "Fixture:供应商排序保存失败,原顺序未改变";
|
||||
if (mode === "error") throw new Error(failure);
|
||||
const save = () => {
|
||||
providerOrders.set(scope, next);
|
||||
return next;
|
||||
};
|
||||
if (mode === "pending" || mode === "pending-error") {
|
||||
return defer("providerOrder", save, mode === "pending-error" ? failure : null);
|
||||
}
|
||||
return clone(save());
|
||||
}
|
||||
|
||||
function uniqueCopyName(requested, names) {
|
||||
const base = requested?.trim() || "Fixture 副本";
|
||||
const used = new Set(names);
|
||||
@@ -843,6 +900,9 @@
|
||||
if (configStateError) throw new Error("Fixture:Codex 无法加载配置,Model provider my_codex not found。");
|
||||
return clone(state);
|
||||
case "list_saved_providers": return clone(savedProviders);
|
||||
case "get_provider_order": return getProviderOrder(args.configDir);
|
||||
case "save_provider_order": return saveProviderOrder(args);
|
||||
case "repair_provider_model_catalog": return { changed: false };
|
||||
case "list_saved_prompts": return clone(savedPrompts);
|
||||
case "get_builtin_prompt_status": return clone(statuses);
|
||||
case "list_official_profiles":
|
||||
@@ -1030,6 +1090,10 @@
|
||||
loginSecondOfficialAccount,
|
||||
setUsageMode,
|
||||
setQuotaMode,
|
||||
setProviderOrderMode,
|
||||
getProviderOrder,
|
||||
completeProviderOrder: () => settle("providerOrder", false),
|
||||
failProviderOrder: () => settle("providerOrder", true),
|
||||
completeQuota: () => settle("quota", false),
|
||||
failQuota: () => settle("quota", true),
|
||||
completeSync: () => settle("sync", false),
|
||||
@@ -1136,6 +1200,22 @@
|
||||
resetCompleteButton.style.cssText = loginButton.style.cssText;
|
||||
resetCompleteButton.addEventListener("click", () => settle("resetCredits", false));
|
||||
controls.append(resetCompleteButton);
|
||||
const orderModeLabel = document.createElement("label");
|
||||
orderModeLabel.textContent = "Fixture:排序保存 ";
|
||||
const orderModeSelect = document.createElement("select");
|
||||
orderModeSelect.setAttribute("aria-label", "Fixture:排序保存");
|
||||
orderModeSelect.style.cssText = quotaModeSelect.style.cssText;
|
||||
for (const [value, text] of [["normal", "正常"], ["error", "保存失败"], ["pending", "延迟成功"], ["pending-error", "延迟失败"]]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value; option.textContent = text; orderModeSelect.append(option);
|
||||
}
|
||||
orderModeSelect.addEventListener("change", () => setProviderOrderMode(orderModeSelect.value));
|
||||
orderModeLabel.append(orderModeSelect); controls.append(orderModeLabel);
|
||||
for (const [text, failure] of [["Fixture:完成排序保存", false], ["Fixture:使排序保存失败", true]]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button"; button.textContent = text; button.style.cssText = loginButton.style.cssText;
|
||||
button.addEventListener("click", () => settle("providerOrder", failure)); controls.append(button);
|
||||
}
|
||||
const pauseUsageLabel = document.createElement("label");
|
||||
const pauseUsageInput = document.createElement("input");
|
||||
pauseUsageInput.type = "checkbox";
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const desktop = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
let components;
|
||||
async function ui() {
|
||||
if (components) return components;
|
||||
components = (async () => {
|
||||
const scratch = await mkdtemp(join(desktop, "tests/.provider-editor-ui-"));
|
||||
try {
|
||||
const emitted = new Map();
|
||||
async function compile(sourcePath) {
|
||||
if (emitted.has(sourcePath)) return emitted.get(sourcePath);
|
||||
const outputPath = join(scratch, relative(desktop, sourcePath).replace(/\.tsx?$/, ".mjs"));
|
||||
emitted.set(sourcePath, outputPath);
|
||||
let output = ts.transpileModule(await readFile(sourcePath, "utf8"), {
|
||||
fileName: sourcePath,
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText.replace(/import\s*["'][^"']+\.css["'];?/g, "");
|
||||
for (const match of [...output.matchAll(/\bfrom\s*["'](\.[^"']+)["']/g)]) {
|
||||
const specifier = match[1];
|
||||
const base = resolve(dirname(sourcePath), specifier);
|
||||
let dependency;
|
||||
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts")]) {
|
||||
try { await readFile(candidate, "utf8"); dependency = candidate; break; } catch { /* Normal TypeScript resolution candidates. */ }
|
||||
}
|
||||
assert.ok(dependency, `could not resolve ${specifier} from ${sourcePath}`);
|
||||
const dependencyOutput = await compile(dependency);
|
||||
let replacement = relative(dirname(outputPath), dependencyOutput).replaceAll("\\", "/");
|
||||
if (!replacement.startsWith(".")) replacement = `./${replacement}`;
|
||||
output = output.replaceAll(`"${specifier}"`, `"${replacement}"`).replaceAll(`'${specifier}'`, `'${replacement}'`);
|
||||
}
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, output);
|
||||
return outputPath;
|
||||
}
|
||||
const editor = await compile(join(desktop, "src/components/ProviderHeadersEditor.tsx"));
|
||||
const page = await compile(join(desktop, "src/pages/ProvidersPage.tsx"));
|
||||
return { ...await import(pathToFileURL(editor).href), ...await import(pathToFileURL(page).href) };
|
||||
} finally { await rm(scratch, { recursive: true, force: true }); }
|
||||
})();
|
||||
return components;
|
||||
}
|
||||
|
||||
function render(Component, props) {
|
||||
// PageTransition uses a layout effect. SSR deliberately runs neither it nor
|
||||
// the HeadersControl effects; this checks presentation, not Tauri IPC.
|
||||
const warnings = [];
|
||||
const originalError = console.error;
|
||||
try {
|
||||
console.error = (...args) => warnings.push(args.join(" "));
|
||||
const html = renderToStaticMarkup(createElement(Component, props));
|
||||
for (const warning of warnings) assert.match(warning, /^Warning: useLayoutEffect does nothing on the server/);
|
||||
return html;
|
||||
} finally { console.error = originalError; }
|
||||
}
|
||||
|
||||
function attributes(tag) {
|
||||
return Object.fromEntries([...tag.matchAll(/\s([A-Za-z][\w:-]*)="([^"]*)"/g)].map(([, name, value]) => [name, value]));
|
||||
}
|
||||
function elements(html, name) {
|
||||
const pattern = name === "input" ? /<input\b[^>]*>/g : new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?<\\/${name}>`, "g");
|
||||
return [...html.matchAll(pattern)].map(([tag]) => ({ tag, attrs: attributes(tag.slice(0, tag.indexOf(">") + 1)) }));
|
||||
}
|
||||
function control(html, name, label) {
|
||||
const found = elements(html, name).find((element) => element.attrs["aria-label"] === label);
|
||||
assert.ok(found, `${name} with accessible label ${label}`);
|
||||
return found;
|
||||
}
|
||||
function selectedOption(select) {
|
||||
const selected = elements(select.tag, "option").filter(({ attrs }) => "selected" in attrs);
|
||||
assert.equal(selected.length, 1, "a protocol/source selector must have one explicit selected option");
|
||||
return selected[0];
|
||||
}
|
||||
function protocolSelect(html) {
|
||||
const found = elements(html, "select").find(({ tag }) => elements(tag, "option").some(({ attrs }) => attrs.value === "responses"));
|
||||
assert.ok(found, "the actual provider form must include its upstream protocol selector");
|
||||
return found;
|
||||
}
|
||||
const noop = () => {};
|
||||
const validRows = [
|
||||
{ name: "X-Title", value: "Fixture Project", source: "static" },
|
||||
{ name: "X-Project", value: "FIXTURE_PROJECT_NAME", source: "env" },
|
||||
];
|
||||
function formProps(lang, protocol = "responses", overrides = {}) {
|
||||
const text = (zh, en) => lang === "zh" ? zh : en;
|
||||
return {
|
||||
lang, mode: "form", creatingProvider: false, editingProviderId: "fixture-provider",
|
||||
selectedPresetId: "custom", selectedPresetVariantId: "custom",
|
||||
loading: false, fetchingModels: false, apiKeyVisible: false, availableModels: [],
|
||||
providerForm: { providerName: "Fixture provider", baseUrl: "https://fixture.example/v1", apiKey: "", model: "fixture-model", wireApi: "responses", upstreamApi: protocol, requiresOpenaiAuth: false },
|
||||
providerModelMappings: [], providerAuthPreview: "Fixture auth preview",
|
||||
providerTomlDraft: 'model_provider = "custom"\n[model_providers.custom]\nname = "Fixture provider"\nhttp_headers = { "X-Title" = "Fixture Project" }\n',
|
||||
copy: {
|
||||
formEyebrow: "Fixture", formAddTitle: text("新增供应商", "Add provider"), formEditTitle: text("编辑供应商", "Edit provider"),
|
||||
formHint: text("供应商配置", "Provider configuration"), cancelLabel: text("取消", "Cancel"),
|
||||
apiConfigTitle: "API", apiConfigDescription: text("接口设置", "API settings"), apiKeyLabel: "API Key", apiKeyPlaceholder: "Fixture API key",
|
||||
showApiKeyLabel: text("显示密钥", "Show API key"), hideApiKeyLabel: text("隐藏密钥", "Hide API key"), baseUrlLabel: "Base URL",
|
||||
nameLabel: text("供应商名称", "Provider name"), modelLabel: text("模型", "Model"), fetchModelsLabel: text("获取模型", "Fetch models"), fetchingModelsLabel: text("获取中", "Fetching"),
|
||||
requiresAuthLabel: text("使用 OpenAI 认证", "Use OpenAI authentication"), authPreviewTitle: text("认证预览", "Authentication preview"), authPreviewDescription: "Fixture preview",
|
||||
tomlTitle: "config.toml", tomlDescription: "Fixture TOML", resetTomlLabel: text("重置 TOML", "Reset TOML"), saveLabel: text("保存供应商", "Save provider"), savingLabel: text("保存中", "Saving"),
|
||||
},
|
||||
onPresetSelect: noop, onPresetVariantSelect: noop, onProviderModelMappingsChange: noop, onCancelMode: noop,
|
||||
onApiKeyChange: noop, onBaseUrlChange: noop, onProviderNameChange: noop, onProviderModelChange: noop, onFetchModels: noop,
|
||||
onUpstreamApiChange: noop, onRequiresAuthChange: noop, onToggleApiKeyVisibility: noop,
|
||||
onProviderTomlDraftChange: noop, onProviderHeadersConfigChange: noop, onResetProviderToml: noop, onSaveProvider: noop,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
for (const lang of ["zh", "en"]) {
|
||||
const zh = lang === "zh";
|
||||
const rowLabel = (index) => zh ? `第 ${index + 1} 行` : `Row ${index + 1}`;
|
||||
const nameLabel = zh ? "名称" : "Name";
|
||||
const valueLabel = zh ? "值" : "Value";
|
||||
const sourceLabel = zh ? "值来源" : "Value source";
|
||||
const envLabel = zh ? "环境变量" : "Environment variable";
|
||||
|
||||
test(`model discovery allows authentication headers without requiring an API key field (${lang})`, async () => {
|
||||
const { ProvidersPage } = await ui();
|
||||
const props = formProps(lang);
|
||||
const label = zh ? "获取模型" : "Fetch models";
|
||||
assert.equal(props.providerForm.apiKey, "");
|
||||
const html = render(ProvidersPage, props);
|
||||
assert.equal(control(html, "button", label).attrs.disabled, undefined);
|
||||
const missingUrl = render(ProvidersPage, { ...props, providerForm: { ...props.providerForm, baseUrl: "" } });
|
||||
assert.notEqual(control(missingUrl, "button", label).attrs.disabled, undefined);
|
||||
});
|
||||
|
||||
test(`Headers editor renders literal and environment rows with accessible help (${lang})`, async () => {
|
||||
const { ProviderHeadersEditor } = await ui();
|
||||
const html = render(ProviderHeadersEditor, { lang, rows: validRows, onChange: noop });
|
||||
assert.ok(html.includes(`role="group" aria-label="${rowLabel(0)}"`));
|
||||
assert.ok(html.includes(`role="group" aria-label="${rowLabel(1)}"`));
|
||||
assert.equal(control(html, "input", `${nameLabel} · ${rowLabel(0)}`).attrs.value, "X-Title");
|
||||
assert.equal(control(html, "input", `${valueLabel} · ${rowLabel(0)}`).attrs.value, "Fixture Project");
|
||||
assert.equal(selectedOption(control(html, "select", `${sourceLabel} · ${rowLabel(0)}`)).attrs.value, "static");
|
||||
assert.equal(selectedOption(control(html, "select", `${sourceLabel} · ${rowLabel(1)}`)).attrs.value, "env");
|
||||
const env = control(html, "input", `${envLabel} · ${rowLabel(1)}`);
|
||||
assert.equal(env.attrs.value, "FIXTURE_PROJECT_NAME");
|
||||
assert.equal(env.attrs["aria-invalid"], "false");
|
||||
assert.ok(env.attrs["aria-describedby"]);
|
||||
const help = elements(html, "p").find(({ attrs }) => attrs.id === env.attrs["aria-describedby"]);
|
||||
assert.ok(help, "the environment input must reference existing help text");
|
||||
assert.ok(help.tag.includes(zh ? "环境变量模式只保存变量名" : "Environment mode saves only the variable name"));
|
||||
assert.ok(html.includes(zh ? "固定值" : "Literal value"));
|
||||
assert.ok(!html.includes('aria-invalid="true"'));
|
||||
});
|
||||
|
||||
test(`Headers editor exposes row errors through aria-invalid and exact descriptions (${lang})`, async () => {
|
||||
const { ProviderHeadersEditor } = await ui();
|
||||
const rows = [
|
||||
{ name: "bad name", value: "ok", source: "static" },
|
||||
{ name: "X-Env", value: "2INVALID_ENV", source: "env" },
|
||||
{ name: "X-Line", value: "one\ntwo", source: "static" },
|
||||
{ name: "X-Source", value: "ok", source: "unknown" },
|
||||
{ name: "X-Title", value: "one", source: "static" },
|
||||
{ name: "x-title", value: "FIXTURE_TITLE", source: "env" },
|
||||
];
|
||||
const html = render(ProviderHeadersEditor, { lang, rows, onChange: noop });
|
||||
const cases = [
|
||||
["input", `${nameLabel} · ${rowLabel(0)}`, zh ? "名称只能使用" : "Use letters, numbers"],
|
||||
["input", `${envLabel} · ${rowLabel(1)}`, zh ? "请填写环境变量名" : "Enter an environment variable name"],
|
||||
["input", `${valueLabel} · ${rowLabel(2)}`, zh ? "不能包含换行或控制字符" : "cannot contain line breaks or control characters"],
|
||||
["select", `${sourceLabel} · ${rowLabel(3)}`, zh ? "请选择固定值或环境变量" : "Choose a literal value or environment variable"],
|
||||
["input", `${nameLabel} · ${rowLabel(4)}`, zh ? "此名称重复" : "Duplicate header name"],
|
||||
["input", `${nameLabel} · ${rowLabel(5)}`, zh ? "此名称重复" : "Duplicate header name"],
|
||||
];
|
||||
const errorIds = new Set();
|
||||
for (const [element, label, message] of cases) {
|
||||
const field = control(html, element, label);
|
||||
assert.equal(field.attrs["aria-invalid"], "true", label);
|
||||
const errorId = field.attrs["aria-describedby"];
|
||||
assert.ok(errorId, `error description for ${label}`);
|
||||
assert.ok(!errorIds.has(errorId), "each field must reference its own row error");
|
||||
errorIds.add(errorId);
|
||||
const error = elements(html, "small").find(({ attrs }) => attrs.id === errorId);
|
||||
assert.ok(error?.tag.includes(message), `specific visible error for ${label}`);
|
||||
}
|
||||
assert.ok(html.includes(zh ? "请先修正标红的 Headers" : "Correct the highlighted headers"));
|
||||
assert.equal(control(html, "input", `${valueLabel} · ${rowLabel(0)}`).attrs["aria-invalid"], "false", "a name error must not mark that row's valid value invalid");
|
||||
});
|
||||
|
||||
test(`Headers editor offers explicit UA presets and header-specific examples (${lang})`, async () => {
|
||||
const { ProviderHeadersEditor } = await ui();
|
||||
const empty = render(ProviderHeadersEditor, { lang, rows: [], onChange: noop });
|
||||
const preset = control(empty, "select", zh ? "User-Agent 预设" : "User-Agent presets");
|
||||
assert.deepEqual(elements(preset.tag, "option").map(({ attrs }) => attrs.value), ["", "claude_code", "kilo_code"]);
|
||||
assert.ok(preset.tag.includes("claude-cli/2.1.161 (external, cli)"));
|
||||
assert.ok(preset.tag.includes("Kilo-Code/1.0"));
|
||||
assert.equal(elements(empty, "input").length, 0, "mounting must not create headers from presets");
|
||||
assert.ok(empty.includes(zh ? "其他请求头保留" : "keep other headers"));
|
||||
const names = ["User-Agent", "HTTP-Referer", "X-Title", "Authorization"];
|
||||
const html = render(ProviderHeadersEditor, { lang, rows: names.map((name) => ({ name, value: "", source: "static" })), onChange: noop });
|
||||
const placeholders = names.map((_, index) => control(html, "input", `${valueLabel} · ${rowLabel(index)}`).attrs.placeholder);
|
||||
assert.equal(new Set(placeholders).size, names.length);
|
||||
assert.ok(placeholders.every((placeholder) => !placeholder.includes("Codex-X")));
|
||||
assert.ok(placeholders[1].includes("https://your-app.example"));
|
||||
assert.ok(placeholders[3].includes("Bearer "));
|
||||
});
|
||||
|
||||
test(`disabled Headers editor disables every row control (${lang})`, async () => {
|
||||
const { ProviderHeadersEditor } = await ui();
|
||||
const html = render(ProviderHeadersEditor, { lang, rows: validRows, disabled: true, onChange: noop });
|
||||
const controls = ["input", "select", "button"].flatMap((name) => elements(html, name));
|
||||
assert.equal(controls.length, 10);
|
||||
assert.ok(controls.every(({ attrs }) => "disabled" in attrs));
|
||||
});
|
||||
|
||||
test(`provider form contains the real HeadersControl and all four API choices (${lang})`, async () => {
|
||||
const { ProvidersPage } = await ui();
|
||||
let configChanges = 0;
|
||||
const html = render(ProvidersPage, formProps(lang, "responses", { onProviderHeadersConfigChange() { configChanges += 1; } }));
|
||||
assert.ok(html.includes('class="cx-provider-headers-control"'));
|
||||
assert.ok(html.includes('class="cx-provider-headers"'));
|
||||
assert.ok(html.includes(zh ? "正在读取 Headers…" : "Reading headers…"));
|
||||
assert.ok(html.includes(zh ? "尚未配置自定义 Headers" : "No custom headers configured"), "SSR shows initial control state and does not run the TOML-read effect");
|
||||
assert.equal(configChanges, 0);
|
||||
const select = protocolSelect(html);
|
||||
assert.deepEqual(elements(select.tag, "option").map(({ attrs }) => attrs.value), ["responses", "chat_completions", "anthropic_messages", "gemini"]);
|
||||
for (const label of ["OpenAI Responses", "OpenAI Chat Completions", "Claude Messages", "Gemini generateContent"]) assert.ok(select.tag.includes(label));
|
||||
assert.equal(selectedOption(select).attrs.value, "responses");
|
||||
assert.ok(html.includes(zh ? "上游接口协议" : "Upstream API protocol"));
|
||||
});
|
||||
|
||||
test(`converted APIs select their actual format and explain router lifetime (${lang})`, async () => {
|
||||
const { ProvidersPage } = await ui();
|
||||
for (const protocol of ["chat_completions", "anthropic_messages", "gemini"]) {
|
||||
const html = render(ProvidersPage, formProps(lang, protocol));
|
||||
assert.equal(selectedOption(protocolSelect(html)).attrs.value, protocol);
|
||||
const hint = elements(html, "p").find(({ attrs }) => attrs.class?.includes("cx-providers-protocol-hint"));
|
||||
assert.ok(hint, `routing explanation for ${protocol}`);
|
||||
for (const text of zh ? ["Codex 仍使用 Responses", "开启本地路由和配置接管", "保持 Codex-X 运行", "退出期间请求会暂停", "重开后自动恢复", "切回 Responses"] : ["Codex still uses Responses", "local router and config takeover", "keep Codex-X running", "before disabling the router", "resume after reopening"]) assert.ok(hint.tag.includes(text), text);
|
||||
}
|
||||
});
|
||||
|
||||
test(`native Responses hides conversion guidance, while unknown APIs remain visibly unsupported (${lang})`, async () => {
|
||||
const { ProvidersPage } = await ui();
|
||||
for (const upstreamApi of [undefined, "responses"]) {
|
||||
const html = render(ProvidersPage, formProps(lang, upstreamApi, { providerForm: { ...formProps(lang).providerForm, upstreamApi } }));
|
||||
assert.equal(selectedOption(protocolSelect(html)).attrs.value, "responses");
|
||||
assert.ok(!html.includes("cx-providers-protocol-hint"));
|
||||
}
|
||||
for (const providerForm of [
|
||||
{ ...formProps(lang).providerForm, upstreamApi: "fixture_unknown_api" },
|
||||
{ ...formProps(lang).providerForm, upstreamApi: null, wireApi: "fixture_legacy_api" },
|
||||
]) {
|
||||
const html = render(ProvidersPage, formProps(lang, "responses", { providerForm }));
|
||||
const chosen = selectedOption(protocolSelect(html));
|
||||
assert.equal(chosen.attrs.value, providerForm.upstreamApi || providerForm.wireApi);
|
||||
assert.ok("disabled" in chosen.attrs);
|
||||
assert.ok(chosen.tag.includes(zh ? "不支持的协议,请重新选择" : "Unsupported protocol"));
|
||||
assert.notEqual(chosen.attrs.value, "responses");
|
||||
}
|
||||
const legacyChat = render(ProvidersPage, formProps(lang, "responses", { providerForm: { ...formProps(lang).providerForm, upstreamApi: null, wireApi: "chat" } }));
|
||||
assert.equal(selectedOption(protocolSelect(legacyChat)).attrs.value, "chat_completions");
|
||||
assert.ok(legacyChat.includes("cx-providers-protocol-hint"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
PROVIDER_HEADER_PRESETS,
|
||||
applyProviderHeaderPreset,
|
||||
providerHeaderValuePlaceholder,
|
||||
} from "../src/providerHeaderPresets.ts";
|
||||
import { validateProviderHeaders } from "../src/providerHeaders.ts";
|
||||
|
||||
const row = (name, value = "", source = "static") => ({ name, value, source });
|
||||
|
||||
test("two explicit UA presets retain their reference values and bilingual labels", () => {
|
||||
assert.deepEqual(PROVIDER_HEADER_PRESETS.map(({ id, name, value, label }) => ({ id, name, value, label })), [
|
||||
{ id: "claude_code", name: "User-Agent", value: "claude-cli/2.1.161 (external, cli)", label: { zh: "Claude Code", en: "Claude Code" } },
|
||||
{ id: "kilo_code", name: "User-Agent", value: "Kilo-Code/1.0", label: { zh: "Kilo Code", en: "Kilo Code" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("selecting a preset on an empty configuration creates only one static UA header", () => {
|
||||
for (const preset of PROVIDER_HEADER_PRESETS) {
|
||||
const configured = applyProviderHeaderPreset([], preset.id);
|
||||
assert.deepEqual(configured, [row("User-Agent", preset.value)]);
|
||||
assert.equal(validateProviderHeaders(configured).valid, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting the same preset repeatedly is idempotent and switching presets updates that row", () => {
|
||||
const first = applyProviderHeaderPreset([], "claude_code");
|
||||
const second = applyProviderHeaderPreset(first, "claude_code");
|
||||
assert.deepEqual(second, first);
|
||||
assert.deepEqual(applyProviderHeaderPreset(second, "kilo_code"), [row("User-Agent", "Kilo-Code/1.0")]);
|
||||
assert.equal(first[0].value, "claude-cli/2.1.161 (external, cli)");
|
||||
});
|
||||
|
||||
test("an existing environment UA is updated case insensitively without changing other sensitive values", () => {
|
||||
const original = [
|
||||
row("Authorization", " Bearer fixture-token "),
|
||||
row(" uSeR-aGeNt ", "PROVIDER_UA", "env"),
|
||||
row("x-api-key", " fixture-key\t"),
|
||||
row("X-Project", "PROJECT_ENV", "env"),
|
||||
];
|
||||
const selected = applyProviderHeaderPreset(original, "claude_code");
|
||||
assert.deepEqual(selected, [original[0], row("User-Agent", "claude-cli/2.1.161 (external, cli)"), original[2], original[3]]);
|
||||
assert.equal(selected[0], original[0]);
|
||||
assert.equal(selected[2], original[2]);
|
||||
assert.equal(selected[3], original[3]);
|
||||
assert.deepEqual(original[1], row(" uSeR-aGeNt ", "PROVIDER_UA", "env"));
|
||||
});
|
||||
|
||||
test("only the first UA is updated and duplicate names remain visible to validation", () => {
|
||||
const original = [row("", ""), row("USER-AGENT", "old-first"), row("user-agent", "SECOND_UA", "env"), row("X-Title", "App")];
|
||||
const selected = applyProviderHeaderPreset(original, "kilo_code");
|
||||
assert.equal(selected.length, original.length);
|
||||
assert.equal(selected[0], original[0]);
|
||||
assert.deepEqual(selected[1], row("User-Agent", "Kilo-Code/1.0"));
|
||||
assert.equal(selected[2], original[2]);
|
||||
assert.equal(selected[3], original[3]);
|
||||
const validation = validateProviderHeaders(selected);
|
||||
assert.equal(validation.valid, false);
|
||||
assert.ok(validation.errors[1].name.includes("Duplicate"));
|
||||
assert.ok(validation.errors[2].name.includes("Duplicate"));
|
||||
});
|
||||
|
||||
test("a completely empty draft row is reused before appending a UA", () => {
|
||||
const original = [row("X-Title", "App"), row(" \t ", "", "env"), row("", "")];
|
||||
const selected = applyProviderHeaderPreset(original, "claude_code");
|
||||
assert.equal(selected.length, 3);
|
||||
assert.equal(selected[0], original[0]);
|
||||
assert.deepEqual(selected[1], row("User-Agent", "claude-cli/2.1.161 (external, cli)"));
|
||||
assert.equal(selected[2], original[2]);
|
||||
assert.equal(original[1].source, "env");
|
||||
});
|
||||
|
||||
test("unfinished drafts containing values are preserved and a UA is appended", () => {
|
||||
const original = [row("", "unfinished secret"), row(" ", " "), row("X-Title", "App")];
|
||||
const selected = applyProviderHeaderPreset(original, "kilo_code");
|
||||
assert.deepEqual(selected, [...original, row("User-Agent", "Kilo-Code/1.0")]);
|
||||
for (let index = 0; index < original.length; index++) assert.equal(selected[index], original[index]);
|
||||
});
|
||||
|
||||
test("unknown preset IDs are a no-op and never overwrite a blank draft", () => {
|
||||
const original = [row("Authorization", " fixture-token "), row("", "")];
|
||||
assert.deepEqual(applyProviderHeaderPreset(original, "unknown"), original);
|
||||
assert.equal(applyProviderHeaderPreset(original, "unknown")[0], original[0]);
|
||||
assert.deepEqual(applyProviderHeaderPreset([], ""), []);
|
||||
});
|
||||
|
||||
test("literal placeholders explain each header's purpose without a generic Codex-X value", () => {
|
||||
for (const lang of ["zh", "en"]) {
|
||||
const names = ["User-Agent", "HTTP-Referer", "X-Title", "Authorization", "x-api-key", "X-Custom"];
|
||||
const hints = names.map((name) => providerHeaderValuePlaceholder(name, lang));
|
||||
assert.equal(new Set(hints).size, names.length);
|
||||
assert.ok(hints.every((hint) => hint && !hint.includes("Codex-X")));
|
||||
assert.ok(hints[0].includes("claude-cli/"));
|
||||
assert.ok(hints[1].includes("https://"));
|
||||
assert.ok(hints[3].includes("Bearer"));
|
||||
assert.ok(hints[4].includes("API key") || hints[4].includes("API Key"));
|
||||
assert.equal(providerHeaderValuePlaceholder("x-OpenRouter-Title", lang), hints[2]);
|
||||
assert.equal(providerHeaderValuePlaceholder("X-GOOG-API-KEY", lang), hints[4]);
|
||||
assert.equal(providerHeaderValuePlaceholder(" user-agent ", lang), hints[0]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createProviderHeader,
|
||||
findProviderHeader,
|
||||
normalizeProviderHeaders,
|
||||
removeProviderHeader,
|
||||
updateProviderHeader,
|
||||
validateProviderHeaders,
|
||||
} from "../src/providerHeaders.ts";
|
||||
|
||||
const header = (name, value = "Codex-X", source = "static") => ({ name, value, source });
|
||||
|
||||
test("normalization retains original values and supports static/environment headers", () => {
|
||||
const rows = [header(" X-Title ", " keep whitespace "), header("X-Project", "PROJECT_KEY", "env")];
|
||||
assert.equal(validateProviderHeaders(rows).valid, true);
|
||||
assert.deepEqual(normalizeProviderHeaders(rows), [header("X-Title", " keep whitespace "), rows[1]]);
|
||||
assert.equal(findProviderHeader(rows, "x-title"), rows[0]);
|
||||
assert.equal(rows[0].name, " X-Title ");
|
||||
});
|
||||
|
||||
test("duplicate names are rejected across sources and letter case", () => {
|
||||
const result = validateProviderHeaders([header("User-Agent"), header("user-agent", "CUSTOM_UA", "env")], "zh");
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.errors.every((error) => error.name.includes("重复")));
|
||||
});
|
||||
|
||||
test("header names follow HTTP token syntax", () => {
|
||||
for (const name of ["", "bad name", "bad:name", "x\r\ninjected", "中文", "x\u007f"]) {
|
||||
assert.equal(validateProviderHeaders([header(name)]).valid, false, name);
|
||||
}
|
||||
assert.equal(validateProviderHeaders([header("!#$%&'*+-.^_`|~09AZaz")]).valid, true);
|
||||
});
|
||||
|
||||
test("values reject CRLF and other control characters without exposing secrets in errors", () => {
|
||||
for (const value of ["secret\r\nInjected: yes", "secret\n", "secret\u0000", "secret\u000b", "secret\u007f"]) {
|
||||
const result = validateProviderHeaders([header("X-Test", value)]);
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(JSON.stringify(result.errors).includes("secret"), false);
|
||||
}
|
||||
assert.equal(validateProviderHeaders([header("X-Test", ""), header("X-Other", "text\t中文")]).valid, true);
|
||||
});
|
||||
|
||||
test("environment sources store names, rejecting pasted values and invalid identifiers", () => {
|
||||
for (const value of ["", "9PROJECT", "PROJECT KEY", "PROJECT=secret", "secret\r\n", " key "]) {
|
||||
assert.equal(validateProviderHeaders([header("X-Project", value, "env")]).valid, false);
|
||||
}
|
||||
assert.equal(validateProviderHeaders([header("X-Project", "_PROJECT_9", "env")]).valid, true);
|
||||
});
|
||||
|
||||
test("incomplete drafts and unsupported source types cannot be silently saved", () => {
|
||||
assert.equal(validateProviderHeaders([createProviderHeader()]).valid, false);
|
||||
assert.equal(validateProviderHeaders([header("X-Test", "value", "unsupported")]).valid, false);
|
||||
assert.throws(() => normalizeProviderHeaders([createProviderHeader()]), /Invalid provider headers/);
|
||||
assert.deepEqual(normalizeProviderHeaders([]), []);
|
||||
});
|
||||
|
||||
test("row editing and removal preserve other rows without mutating the input", () => {
|
||||
const original = [header("X-Title"), header("X-Project", "PROJECT", "env")];
|
||||
const changed = updateProviderHeader(original, 0, { name: "User-Agent", value: "Agent" });
|
||||
assert.deepEqual(changed[0], header("User-Agent", "Agent"));
|
||||
assert.equal(changed[1], original[1]);
|
||||
assert.equal(original[0].name, "X-Title");
|
||||
assert.deepEqual(removeProviderHeader(changed, 0), [original[1]]);
|
||||
assert.deepEqual(createProviderHeader("env"), header("", "", "env"));
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createProviderHeadersControl } from "../src/providerHeadersControl.ts";
|
||||
import { normalizeProviderHeaders, validateProviderHeaders } from "../src/providerHeaders.ts";
|
||||
|
||||
const row = (name = "X-Title", value = "Fixture", source = "static") => ({ name, value, source });
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((accept, fail) => { resolve = accept; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function fixture(text = "source-a") {
|
||||
let currentText = text;
|
||||
const reads = [], writes = [], events = [], published = [];
|
||||
const control = createProviderHeadersControl({
|
||||
read(configText) { const task = deferred(); reads.push({ configText, ...task }); return task.promise; },
|
||||
update(configText, headers) { const task = deferred(); writes.push({ configText, headers, ...task }); return task.promise; },
|
||||
getConfigText: () => currentText,
|
||||
isValid: (rows) => validateProviderHeaders(rows).valid,
|
||||
normalize: normalizeProviderHeaders,
|
||||
onRowsChange: (rows) => events.push({ rows }),
|
||||
onConfigChange: (configText) => published.push(configText),
|
||||
onStateChange: (state) => events.push({ state }),
|
||||
});
|
||||
return { control, reads, writes, events, published, setText(text) { currentText = text; }, latestState() { return events.findLast((event) => event.state)?.state; } };
|
||||
}
|
||||
|
||||
test("read requests never replace rows from a newer source or after the source changed before its effect", async () => {
|
||||
const f = fixture();
|
||||
const first = f.control.load("source-a");
|
||||
f.setText("source-b");
|
||||
f.reads[0].resolve([row("Old", "old")]);
|
||||
await first;
|
||||
assert.equal(f.events.some((event) => event.rows), false);
|
||||
const second = f.control.load("source-b");
|
||||
f.reads[1].resolve([row("New", "new")]);
|
||||
await second;
|
||||
assert.deepEqual(f.events.filter((event) => event.rows).at(-1).rows, [row("New", "new")]);
|
||||
assert.equal(f.latestState().valid, true);
|
||||
});
|
||||
|
||||
test("invalid rows stay editable and prevent saving without starting IPC updates", async () => {
|
||||
const f = fixture();
|
||||
const loading = f.control.load("source-a");
|
||||
await f.control.changeRows([row("", "")]);
|
||||
f.reads[0].resolve([row("Stale")]);
|
||||
await loading;
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.deepEqual(f.events.filter((event) => event.rows).at(-1).rows, [row("", "")]);
|
||||
assert.deepEqual(f.latestState(), { busy: false, reading: false, valid: false, error: null });
|
||||
});
|
||||
|
||||
test("rapid valid edits accept only the latest write and normalize names without trimming values", async () => {
|
||||
const f = fixture();
|
||||
const first = f.control.changeRows([row(" X-Title ", "first")]);
|
||||
const second = f.control.changeRows([row(" X-Title ", " last ")]);
|
||||
assert.deepEqual(f.writes[1].headers, [row("X-Title", " last ")]);
|
||||
f.writes[1].resolve("patched-last");
|
||||
await second;
|
||||
f.writes[0].resolve("patched-first");
|
||||
await first;
|
||||
assert.deepEqual(f.published, ["patched-last"]);
|
||||
assert.equal(f.latestState().busy, false);
|
||||
});
|
||||
|
||||
test("a new TOML source rebases edited headers and cannot be overwritten by an old response", async () => {
|
||||
const f = fixture();
|
||||
const first = f.control.changeRows([row("X-Title", "edited")]);
|
||||
f.setText("source-b-with-new-model");
|
||||
const rebased = f.control.load("source-b-with-new-model");
|
||||
assert.equal(f.writes[1].configText, "source-b-with-new-model");
|
||||
f.writes[0].resolve("old-source-result");
|
||||
await first;
|
||||
assert.deepEqual(f.published, []);
|
||||
f.writes[1].resolve("new-source-with-edited-headers");
|
||||
await rebased;
|
||||
assert.deepEqual(f.published, ["new-source-with-edited-headers"]);
|
||||
});
|
||||
|
||||
test("a local invalid draft survives an unrelated TOML change", async () => {
|
||||
const f = fixture();
|
||||
await f.control.changeRows([row("invalid name", "keep-editing")]);
|
||||
f.setText("changed-model");
|
||||
await f.control.load("changed-model");
|
||||
assert.equal(f.reads.length, 0);
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.equal(f.latestState().valid, false);
|
||||
assert.deepEqual(f.events.filter((event) => event.rows).at(-1).rows, [row("invalid name", "keep-editing")]);
|
||||
});
|
||||
|
||||
test("the parent echo of a successful patch does not reread or erase local drafts", async () => {
|
||||
const f = fixture();
|
||||
const writing = f.control.changeRows([row()]);
|
||||
f.writes[0].resolve("patched-a");
|
||||
await writing;
|
||||
f.setText("patched-a");
|
||||
await f.control.load("patched-a");
|
||||
assert.equal(f.reads.length, 0);
|
||||
assert.equal(f.writes.length, 1);
|
||||
assert.equal(f.latestState().valid, true);
|
||||
});
|
||||
|
||||
test("editing before an earlier patch is echoed rebases the latest edit against that patch", async () => {
|
||||
const f = fixture();
|
||||
const first = f.control.changeRows([row("X-Title", "first")]);
|
||||
f.writes[0].resolve("patched-a");
|
||||
await first;
|
||||
const second = f.control.changeRows([row("X-Title", "second")]);
|
||||
f.setText("patched-a");
|
||||
const rebase = f.control.load("patched-a");
|
||||
f.writes[1].resolve("stale-second");
|
||||
await second;
|
||||
f.writes[2].resolve("patched-second");
|
||||
await rebase;
|
||||
assert.equal(f.writes[2].configText, "patched-a");
|
||||
assert.deepEqual(f.published, ["patched-a", "patched-second"]);
|
||||
});
|
||||
|
||||
test("unmount invalidates pending reads and writes without emitting events", async () => {
|
||||
for (const operation of ["read", "write"]) {
|
||||
const f = fixture();
|
||||
const task = operation === "read" ? f.control.load("source-a") : f.control.changeRows([row()]);
|
||||
const count = f.events.length;
|
||||
f.control.dispose();
|
||||
if (operation === "read") f.reads[0].resolve([row("Stale")]);
|
||||
else f.writes[0].resolve("stale-result");
|
||||
await task;
|
||||
assert.equal(f.events.length, count);
|
||||
assert.deepEqual(f.published, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("IPC failures expose only a generic operation marker and can be retried", async () => {
|
||||
const f = fixture();
|
||||
const loading = f.control.load("source-a");
|
||||
f.reads[0].reject(new Error("header-secret\nresolved-env-secret"));
|
||||
await loading;
|
||||
assert.equal(f.latestState().error, "read");
|
||||
const retry = f.control.retry();
|
||||
f.reads[1].resolve([row()]);
|
||||
await retry;
|
||||
const writing = f.control.changeRows([row("X-Title", "changed")]);
|
||||
f.writes[0].reject(new Error("header-secret\nresolved-env-secret"));
|
||||
await writing;
|
||||
assert.equal(f.latestState().error, "write");
|
||||
assert.equal(JSON.stringify(f.events).includes("header-secret"), false);
|
||||
const writeRetry = f.control.retry();
|
||||
f.writes[1].resolve("retry-success");
|
||||
await writeRetry;
|
||||
assert.deepEqual(f.published, ["retry-success"]);
|
||||
assert.equal(f.latestState().valid, true);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { providerUpstreamApi, providerRequiresConversion } from "../src/providerProtocol.ts";
|
||||
|
||||
test("all upstream choices are retained independently of the native wire API", () => {
|
||||
for (const upstreamApi of ["responses", "chat_completions", "anthropic_messages", "gemini"]) {
|
||||
assert.equal(providerUpstreamApi({ upstreamApi, wireApi: "responses" }), upstreamApi);
|
||||
assert.equal(providerRequiresConversion({ upstreamApi, wireApi: "responses" }), upstreamApi !== "responses");
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy chat and protocol aliases remain editable", () => {
|
||||
assert.equal(providerUpstreamApi({ wireApi: "chat" }), "chat_completions");
|
||||
assert.equal(providerUpstreamApi({ wireApi: "chat_completions" }), "chat_completions");
|
||||
assert.equal(providerUpstreamApi({ upstreamApi: "openai_chat" }), "chat_completions");
|
||||
assert.equal(providerUpstreamApi({ upstreamApi: "anthropic" }), "anthropic_messages");
|
||||
assert.equal(providerUpstreamApi({ upstreamApi: "gemini_native" }), "gemini");
|
||||
});
|
||||
|
||||
test("an explicit native selection clears a legacy chat choice", () => {
|
||||
assert.equal(providerUpstreamApi({ wireApi: "chat", upstreamApi: "responses" }), "responses");
|
||||
assert.equal(providerRequiresConversion({ wireApi: "responses", upstreamApi: null }), false);
|
||||
});
|
||||
|
||||
test("unknown metadata is retained so saving cannot silently switch protocols", () => {
|
||||
assert.equal(providerUpstreamApi({ upstreamApi: "unsupported", wireApi: "responses" }), "unsupported");
|
||||
assert.equal(providerUpstreamApi({ wireApi: "unknown_native" }), "unknown_native");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { orderProviderRows } from "../src/providerRowOrder.ts";
|
||||
import { applyProviderRowOrder, moveProviderRow, orderProviderRows, providerRowKey } from "../src/providerRowOrder.ts";
|
||||
|
||||
const official = { id: "official", isCurrent: false };
|
||||
const detected = { id: "detected", isCurrent: true };
|
||||
@@ -25,3 +25,22 @@ test("an unresolved detected current remains visible before saved rows", () => {
|
||||
assert.deepEqual(rows.map((row) => row.id), ["official", "detected", "original", "copy"]);
|
||||
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["detected"]);
|
||||
});
|
||||
|
||||
test("custom order crosses official/local boundaries and appends new cards", () => {
|
||||
const rows = [{ source: "official", id: "default" }, { source: "local", id: "one" }, { source: "local", id: "new" }];
|
||||
assert.deepEqual(applyProviderRowOrder(rows, ["local:one", "official:default"]).map(providerRowKey), ["local:one", "official:default", "local:new"]);
|
||||
});
|
||||
|
||||
test("missing and duplicate stored keys never hide or duplicate cards", () => {
|
||||
const rows = [{ source: "official", id: "same" }, { source: "local", id: "same" }];
|
||||
assert.deepEqual(applyProviderRowOrder(rows, ["detected:missing", "local:same", "local:same"]).map(providerRowKey), ["local:same", "official:same"]);
|
||||
});
|
||||
|
||||
test("moving up and down inserts at the intended edge without losing entries", () => {
|
||||
const original = ["official:default", "local:one", "local:two", "local:three"];
|
||||
assert.deepEqual(moveProviderRow(original, "local:three", "official:default", "before"), ["local:three", "official:default", "local:one", "local:two"]);
|
||||
assert.deepEqual(moveProviderRow(original, "official:default", "local:three", "after"), ["local:one", "local:two", "local:three", "official:default"]);
|
||||
assert.deepEqual(moveProviderRow(original, "local:one", "local:two", "before"), original);
|
||||
assert.deepEqual(moveProviderRow(original, "missing", "local:one", "after"), original);
|
||||
assert.deepEqual(original, ["official:default", "local:one", "local:two", "local:three"]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
SESSION_FOLDER_UNKNOWN_KEY,
|
||||
mergeSessionDirectoryGroups,
|
||||
pinnedSessionDirectoryKeys,
|
||||
readSessionPinPreferences,
|
||||
sessionDirectoryKey,
|
||||
sessionPinStorageKey,
|
||||
sessionsForDirectoryGroup,
|
||||
toggleFolderPins,
|
||||
} from "../src/sessionDirectoryIdentity.ts";
|
||||
|
||||
const sessionIds = (items) => items.map((item) => item.id);
|
||||
|
||||
test("case-sensitive directories have unique folder identities and accurate selection", () => {
|
||||
const upper = "/case-sensitive/Foo";
|
||||
const lower = "/case-sensitive/foo";
|
||||
const identities = { [upper]: "verified:upper", [lower]: "verified:lower" };
|
||||
const groups = mergeSessionDirectoryGroups([
|
||||
[upper, [{ id: "upper-one", cwd: upper }, { id: "upper-two", cwd: upper }]],
|
||||
[lower, [{ id: "lower-one", cwd: lower }, { id: "lower-two", cwd: lower }]],
|
||||
], identities);
|
||||
|
||||
assert.equal(groups.length, 2);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2, "React folder keys must be unique");
|
||||
assert.notEqual(sessionDirectoryKey(upper, identities), sessionDirectoryKey(lower, identities));
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(upper, identities))), ["upper-one", "upper-two"]);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower, identities))), ["lower-one", "lower-two"]);
|
||||
});
|
||||
|
||||
test("case-distinct directories stay separate before filesystem identities are available", () => {
|
||||
const upper = "/offline/Foo";
|
||||
const lower = "/offline/foo";
|
||||
const groups = mergeSessionDirectoryGroups([
|
||||
[upper, [{ id: "upper", cwd: upper }]],
|
||||
[lower, [{ id: "lower", cwd: lower }]],
|
||||
]);
|
||||
|
||||
assert.equal(groups.length, 2);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower))), ["lower"]);
|
||||
assert.deepEqual(sessionsForDirectoryGroup(groups, "unknown-selection"), []);
|
||||
});
|
||||
|
||||
test("filesystem-verified aliases merge into one folder containing every spelling's sessions", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-x-directory-identity-"));
|
||||
try {
|
||||
const target = join(root, "Workspace");
|
||||
const alias = join(root, "Workspace-alias");
|
||||
mkdirSync(target);
|
||||
symlinkSync(target, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
const verifiedKey = (path) => {
|
||||
const metadata = statSync(path, { bigint: true });
|
||||
assert(metadata.isDirectory());
|
||||
return `verified:${metadata.dev}:${metadata.ino}:${realpathSync.native(path)}`;
|
||||
};
|
||||
const identities = { [target]: verifiedKey(target), [alias]: verifiedKey(alias) };
|
||||
assert.equal(identities[target], identities[alias], "the temporary filesystem must confirm the alias");
|
||||
|
||||
const original = Object.freeze([
|
||||
Object.freeze([target, Object.freeze([{ id: "target-one", cwd: target }, { id: "target-two", cwd: target }])]),
|
||||
Object.freeze([alias, Object.freeze([{ id: "alias-one", cwd: alias }])]),
|
||||
]);
|
||||
const groups = mergeSessionDirectoryGroups(original, identities);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 1);
|
||||
assert.equal(groups[0].group, target, "the first folder label is retained");
|
||||
for (const spelling of [target, alias]) {
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(spelling, identities))), ["target-one", "target-two", "alias-one"]);
|
||||
}
|
||||
assert.equal(original[0][1].length, 2, "merging must not mutate parent session arrays");
|
||||
assert.equal(original[1][1].length, 1);
|
||||
assert.equal(sessionPinStorageKey(target, identities[target]), sessionPinStorageKey(alias, identities[alias]));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved paths preserve case, backslashes, trailing spaces and roots", () => {
|
||||
const paths = [
|
||||
"/missing/Foo", "/missing/foo", "/missing/Foo/",
|
||||
"/missing/name", "/missing/name ", "/missing/name ",
|
||||
"/missing/with\\backslash", "/missing/with/backslash",
|
||||
"/", "//", "C:\\", "C:/", " ",
|
||||
SESSION_FOLDER_UNKNOWN_KEY,
|
||||
];
|
||||
const keys = paths.map((path) => sessionDirectoryKey(path));
|
||||
assert.equal(new Set(keys).size, paths.length);
|
||||
for (const path of paths) {
|
||||
assert.equal(sessionDirectoryKey(path), sessionDirectoryKey(path, {}));
|
||||
assert.notEqual(sessionDirectoryKey(path), SESSION_FOLDER_UNKNOWN_KEY);
|
||||
}
|
||||
for (const missing of [undefined, null, ""]) {
|
||||
assert.equal(sessionDirectoryKey(missing), SESSION_FOLDER_UNKNOWN_KEY);
|
||||
}
|
||||
assert.notEqual(sessionDirectoryKey("/"), sessionDirectoryKey(null));
|
||||
});
|
||||
|
||||
test("a nonempty raw path cannot borrow an inherited identity-table property", () => {
|
||||
const identities = Object.create({ "offline/Foo": "verified:unrelated" });
|
||||
assert.equal(sessionDirectoryKey("offline/Foo", identities), sessionDirectoryKey("offline/Foo"));
|
||||
assert.notEqual(sessionDirectoryKey("offline/Foo", identities), "verified:unrelated");
|
||||
});
|
||||
|
||||
test("directory pins remain independent for case-distinct folders", () => {
|
||||
const identities = { "/work/Foo": "verified:Foo", "/work/foo": "verified:foo" };
|
||||
const upper = sessionDirectoryKey("/work/Foo", identities);
|
||||
const lower = sessionDirectoryKey("/work/foo", identities);
|
||||
let stored = toggleFolderPins([], "/work/Foo", identities);
|
||||
assert.deepEqual(stored, ["path:/work/Foo"], "persistent pins must retain the original path");
|
||||
assert(pinnedSessionDirectoryKeys(stored, identities).has(upper));
|
||||
assert(!pinnedSessionDirectoryKeys(stored, identities).has(lower));
|
||||
stored = toggleFolderPins(stored, "/work/foo", identities);
|
||||
assert.deepEqual(stored, ["path:/work/Foo", "path:/work/foo"]);
|
||||
stored = toggleFolderPins(stored, "/work/Foo", identities);
|
||||
assert.deepEqual(stored, ["path:/work/foo"]);
|
||||
assert(!pinnedSessionDirectoryKeys(stored, identities).has(upper));
|
||||
assert(pinnedSessionDirectoryKeys(stored, identities).has(lower));
|
||||
assert.deepEqual(toggleFolderPins(stored, "/work/foo", identities), []);
|
||||
});
|
||||
|
||||
test("persistent raw-path pins survive online, offline and recreated directory identities", () => {
|
||||
const path = "/work/Project ";
|
||||
const online = { [path]: "verified:original-inode" };
|
||||
const recreated = { [path]: "verified:new-inode" };
|
||||
const stored = Object.freeze(toggleFolderPins([], path, online));
|
||||
assert.deepEqual(stored, ["path:/work/Project "]);
|
||||
for (const identities of [online, undefined, {}, recreated]) {
|
||||
assert(pinnedSessionDirectoryKeys(stored, identities).has(sessionDirectoryKey(path, identities)));
|
||||
assert.deepEqual(toggleFolderPins(stored, path, identities), []);
|
||||
}
|
||||
assert(!pinnedSessionDirectoryKeys(stored, recreated).has(online[path]));
|
||||
assert.deepEqual(stored, ["path:/work/Project "], "resolving or toggling must not mutate the saved pins");
|
||||
});
|
||||
|
||||
test("unpinning a verified alias removes every matching raw-path pin and keeps unrelated pins", () => {
|
||||
const target = "/work/Project";
|
||||
const alias = "/links/Project";
|
||||
const other = "/work/Other";
|
||||
const identities = { [target]: "verified:shared", [alias]: "verified:shared", [other]: "verified:other" };
|
||||
const stored = Object.freeze(["path:/work/Project", "path:/links/Project", "path:/work/Other"]);
|
||||
for (const spelling of [target, alias]) {
|
||||
const next = toggleFolderPins(stored, spelling, identities);
|
||||
assert.deepEqual(next, ["path:/work/Other"]);
|
||||
assert(!pinnedSessionDirectoryKeys(next, identities).has(identities[target]));
|
||||
assert(pinnedSessionDirectoryKeys(next, identities).has(identities[other]));
|
||||
}
|
||||
assert.deepEqual(toggleFolderPins([], alias, identities), ["path:/links/Project"]);
|
||||
assert.deepEqual(toggleFolderPins(["path:/work/Project"], alias, identities), []);
|
||||
assert.equal(stored.length, 3);
|
||||
});
|
||||
|
||||
test("case-distinct CODEX_HOME values have independent v2 pin storage", () => {
|
||||
const upper = "/homes/Foo";
|
||||
const lower = "/homes/foo";
|
||||
for (const identities of [undefined, { [upper]: "verified:home-upper", [lower]: "verified:home-lower" }]) {
|
||||
const upperKey = sessionPinStorageKey(upper, identities?.[upper]);
|
||||
const lowerKey = sessionPinStorageKey(lower, identities?.[lower]);
|
||||
assert.notEqual(upperKey, lowerKey);
|
||||
assert(upperKey.startsWith("codexx.sessionPins.v2:"));
|
||||
assert(lowerKey.startsWith("codexx.sessionPins.v2:"));
|
||||
const cache = new Map([
|
||||
[upperKey, JSON.stringify({ folders: ["upper-folder"], sessions: ["upper-session"] })],
|
||||
[lowerKey, JSON.stringify({ folders: ["lower-folder"], sessions: ["lower-session"] })],
|
||||
]);
|
||||
const storage = { getItem: (key) => cache.get(key) ?? null };
|
||||
assert.deepEqual(readSessionPinPreferences(upperKey, storage), { folders: ["upper-folder"], sessions: ["upper-session"] });
|
||||
assert.deepEqual(readSessionPinPreferences(lowerKey, storage), { folders: ["lower-folder"], sessions: ["lower-session"] });
|
||||
}
|
||||
});
|
||||
|
||||
test("an unverified home scope falls back to case-preserving paths without borrowing snapshot IDs", () => {
|
||||
const upper = "/homes/Foo";
|
||||
const lower = "/homes/foo";
|
||||
const snapshotIds = { [upper]: "snapshot:reused-file-id", [lower]: "snapshot:reused-file-id" };
|
||||
assert.equal(sessionDirectoryKey(upper, snapshotIds), sessionDirectoryKey(lower, snapshotIds));
|
||||
const upperKey = sessionPinStorageKey(upper);
|
||||
const lowerKey = sessionPinStorageKey(lower);
|
||||
assert.notEqual(upperKey, lowerKey, "snapshot identities alone must not merge persistent home scopes");
|
||||
assert.equal(sessionPinStorageKey(upper, null), upperKey);
|
||||
assert.equal(sessionPinStorageKey(lower, null), lowerKey);
|
||||
const cache = new Map([[upperKey, JSON.stringify({ folders: ["path:/work/Foo"], sessions: ["upper-only"] })]]);
|
||||
assert.deepEqual(readSessionPinPreferences(lowerKey, { getItem: (key) => cache.get(key) ?? null }), { folders: [], sessions: [] });
|
||||
});
|
||||
|
||||
test("reused physical home IDs with different verified birth scopes have isolated caches", () => {
|
||||
const home = "/homes/Project";
|
||||
const oldScope = "verified-home:device-1:file-42:birth-100";
|
||||
const newScope = "verified-home:device-1:file-42:birth-200";
|
||||
const oldKey = sessionPinStorageKey(home, oldScope);
|
||||
const newKey = sessionPinStorageKey(home, newScope);
|
||||
assert.notEqual(oldKey, newKey);
|
||||
const cache = new Map([[oldKey, JSON.stringify({ folders: ["path:/work/old"], sessions: ["old-generation"] })]]);
|
||||
const storage = { getItem: (key) => cache.get(key) ?? null };
|
||||
assert.deepEqual(readSessionPinPreferences(oldKey, storage), { folders: ["path:/work/old"], sessions: ["old-generation"] });
|
||||
assert.deepEqual(readSessionPinPreferences(newKey, storage), { folders: [], sessions: [] });
|
||||
});
|
||||
|
||||
test("ambiguous legacy v1 pins never populate either case's v2 preferences", () => {
|
||||
const homes = ["/homes/Foo", "/homes/foo"];
|
||||
const legacyKey = "codexx.sessionPins.v1:%2Fhomes%2Ffoo";
|
||||
const cache = new Map([[legacyKey, JSON.stringify({ folders: ["/work/foo"], sessions: ["legacy-session"] })]]);
|
||||
const reads = [];
|
||||
const storage = { getItem: (key) => { reads.push(key); return cache.get(key) ?? null; } };
|
||||
for (const home of homes) {
|
||||
const currentKey = sessionPinStorageKey(home);
|
||||
assert.notEqual(currentKey, legacyKey);
|
||||
assert.deepEqual(readSessionPinPreferences(currentKey, storage), { folders: [], sessions: [] });
|
||||
}
|
||||
assert.equal(new Set(reads).size, 2);
|
||||
assert(reads.every((key) => key.startsWith("codexx.sessionPins.v2:")));
|
||||
assert(!reads.includes(legacyKey));
|
||||
});
|
||||
|
||||
test("unavailable storage readers return empty pin preferences", () => {
|
||||
const storageKey = sessionPinStorageKey("/homes/Project");
|
||||
const denied = { getItem() { throw new Error("fixture storage unavailable"); } };
|
||||
assert.deepEqual(readSessionPinPreferences(storageKey, denied), { folders: [], sessions: [] });
|
||||
const deniedGetter = { get getItem() { throw new Error("fixture storage getter unavailable"); } };
|
||||
assert.deepEqual(readSessionPinPreferences(storageKey, deniedGetter), { folders: [], sessions: [] });
|
||||
});
|
||||
|
||||
test("missing-workspace sessions and root-directory sessions select separate folders", () => {
|
||||
const groups = mergeSessionDirectoryGroups([
|
||||
["No workspace", [{ id: "missing-one", cwd: null }, { id: "missing-two" }]],
|
||||
["/", [{ id: "root", cwd: "/" }]],
|
||||
]);
|
||||
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, SESSION_FOLDER_UNKNOWN_KEY)), ["missing-one", "missing-two"]);
|
||||
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey("/"))), ["root"]);
|
||||
});
|
||||
|
||||
test("missing-workspace and root-directory pins stay independent and persist through identity changes", () => {
|
||||
const rootIdentity = { "/": "verified:root" };
|
||||
let stored = toggleFolderPins([], null, rootIdentity);
|
||||
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY]);
|
||||
stored = toggleFolderPins(stored, "/", rootIdentity);
|
||||
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY, "path:/"]);
|
||||
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(SESSION_FOLDER_UNKNOWN_KEY));
|
||||
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(rootIdentity["/"]));
|
||||
assert(pinnedSessionDirectoryKeys(stored).has(sessionDirectoryKey("/")));
|
||||
stored = toggleFolderPins(stored, undefined, rootIdentity);
|
||||
assert.deepEqual(stored, ["path:/"]);
|
||||
assert.deepEqual(toggleFolderPins(stored, "/", rootIdentity), []);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { AppUpdaterController } from "../src/appUpdaterController.ts";
|
||||
import { releaseInfoFromFallback, releaseInfoFromCheckFailure } from "../src/updateCheckFlow.ts";
|
||||
|
||||
const metadata = {
|
||||
latestVersion: "v0.3.24",
|
||||
htmlUrl: "https://github.com/yynxxxxx/Codex-X/releases/tag/v0.3.24",
|
||||
hasUpdate: true,
|
||||
};
|
||||
|
||||
test("an installed 0.3.21 edition keeps native retry after a failed channel check and a newer release lookup", async () => {
|
||||
let recovery = false;
|
||||
let installs = 0;
|
||||
const update = { rid: 12, currentVersion: "0.3.21", version: "0.3.24", close: async () => {} };
|
||||
const controller = new AppUpdaterController({
|
||||
check: async () => {
|
||||
if (!recovery) throw new Error("request timed out");
|
||||
return update;
|
||||
},
|
||||
install: async (checked) => {
|
||||
assert.equal(checked, update);
|
||||
installs++;
|
||||
return { restartRequired: true };
|
||||
},
|
||||
restart: async () => {},
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {},
|
||||
});
|
||||
assert.equal(await controller.check(), "error");
|
||||
const release = releaseInfoFromFallback(metadata, true);
|
||||
assert.equal(release.updateMethod, "native", "a failed check must not turn an installed edition into download-only");
|
||||
assert.equal(release.hasUpdate, true);
|
||||
assert.equal(release.latestVersion, "v0.3.24");
|
||||
assert.equal(controller.getSnapshot().failure, "check");
|
||||
assert.equal(await controller.downloadAndInstall(), "error");
|
||||
assert.equal(installs, 0, "release metadata is not a checked installer resource");
|
||||
recovery = true;
|
||||
assert.equal(await controller.retry(), "available");
|
||||
assert.equal(await controller.downloadAndInstall(), "ready");
|
||||
assert.equal(installs, 1);
|
||||
});
|
||||
|
||||
test("portable and unsupported editions retain their intentional manual download path", () => {
|
||||
assert.deepEqual(releaseInfoFromFallback(metadata, false), { status: "ok", ...metadata, updateMethod: "download" });
|
||||
assert.equal(releaseInfoFromFallback({ ...metadata, hasUpdate: false }, false).updateMethod, undefined);
|
||||
});
|
||||
|
||||
test("a failed native channel never claims up-to-date from fallback metadata alone", () => {
|
||||
const release = releaseInfoFromFallback({ ...metadata, latestVersion: "v0.3.21", hasUpdate: false }, true);
|
||||
assert.equal(release.status, "error");
|
||||
assert.equal(release.updateMethod, "native");
|
||||
assert.equal(release.hasUpdate, false);
|
||||
});
|
||||
|
||||
test("both checks failing still preserves installed-edition retry instead of a download-only dialog", () => {
|
||||
assert.deepEqual(releaseInfoFromCheckFailure(true), { status: "error", updateMethod: "native" });
|
||||
assert.deepEqual(releaseInfoFromCheckFailure(false), { status: "error", updateMethod: undefined });
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { INITIAL_APP_UPDATER_STATE, appUpdaterCheckFailureMessage } from "../src/appUpdaterController.ts";
|
||||
|
||||
const desktop = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
let component;
|
||||
async function ui() {
|
||||
if (component) return component;
|
||||
component = (async () => {
|
||||
const scratch = await mkdtemp(join(desktop, "tests/.update-dialog-ui-"));
|
||||
try {
|
||||
// SSR cannot render a React DOM portal. Adapt only its container transport
|
||||
// while keeping the real UpdateDialog, ModalShell and Button components.
|
||||
const portal = join(scratch, "portal.mjs");
|
||||
await writeFile(portal, "export function createPortal(children) { return children; }\n");
|
||||
const emitted = new Map();
|
||||
async function compile(sourcePath) {
|
||||
if (emitted.has(sourcePath)) return emitted.get(sourcePath);
|
||||
const outputPath = join(scratch, relative(desktop, sourcePath).replace(/\.tsx?$/, ".mjs"));
|
||||
emitted.set(sourcePath, outputPath);
|
||||
let output = ts.transpileModule(await readFile(sourcePath, "utf8"), {
|
||||
fileName: sourcePath,
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText.replace(/import\s*["'][^"']+\.css["'];?/g, "");
|
||||
for (const match of [...output.matchAll(/\bfrom\s*["'](\.[^"']+)["']/g)]) {
|
||||
const specifier = match[1];
|
||||
const base = resolve(dirname(sourcePath), specifier);
|
||||
let dependency;
|
||||
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts")]) {
|
||||
try { await readFile(candidate, "utf8"); dependency = candidate; break; } catch { /* Normal TypeScript resolution candidates. */ }
|
||||
}
|
||||
assert.ok(dependency, `could not resolve ${specifier} from ${sourcePath}`);
|
||||
let replacement = relative(dirname(outputPath), await compile(dependency)).replaceAll("\\", "/");
|
||||
if (!replacement.startsWith(".")) replacement = `./${replacement}`;
|
||||
output = output.replaceAll(`"${specifier}"`, `"${replacement}"`).replaceAll(`'${specifier}'`, `'${replacement}'`);
|
||||
}
|
||||
if (sourcePath.endsWith(join("components", "ui", "ModalShell.tsx"))) {
|
||||
const portalImport = relative(dirname(outputPath), portal).replaceAll("\\", "/");
|
||||
assert.ok(output.includes('from "react-dom"'), "the only adapted dependency must be ModalShell's portal");
|
||||
output = output.replace('from "react-dom"', `from "${portalImport}"`);
|
||||
}
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, output);
|
||||
return outputPath;
|
||||
}
|
||||
const path = await compile(join(desktop, "src/components/AppDialogs.tsx"));
|
||||
return (await import(pathToFileURL(path).href)).UpdateDialog;
|
||||
} finally { await rm(scratch, { recursive: true, force: true }); }
|
||||
})();
|
||||
return component;
|
||||
}
|
||||
|
||||
async function render(props) {
|
||||
const UpdateDialog = await ui();
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
|
||||
// Effects, event handlers and Tauri IPC never run in renderToStaticMarkup.
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: { body: {} } });
|
||||
try { return renderToStaticMarkup(createElement(UpdateDialog, props)); }
|
||||
finally {
|
||||
if (previousDocument) Object.defineProperty(globalThis, "document", previousDocument);
|
||||
else delete globalThis.document;
|
||||
}
|
||||
}
|
||||
function buttons(html) {
|
||||
return [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(([button]) => button);
|
||||
}
|
||||
const noop = () => {};
|
||||
const props = (lang) => ({ open: true, lang, currentVersion: "0.3.21", latestVersion: "0.3.24", onClose: noop, onDownload: noop });
|
||||
|
||||
for (const lang of ["zh", "en"]) {
|
||||
const zh = lang === "zh";
|
||||
test(`native check failure offers online retry as primary and manual download as fallback (${lang})`, async () => {
|
||||
const state = { ...INITIAL_APP_UPDATER_STATE, phase: "error", failure: "check", checkFailure: "network", errorMessage: appUpdaterCheckFailureMessage("network"), currentVersion: "0.3.21" };
|
||||
const html = await render({ ...props(lang), state, onRetry: noop, onUpdate: noop });
|
||||
assert.ok(html.includes('role="dialog"'));
|
||||
assert.ok(html.includes(zh ? "在线更新检查失败" : "Online update check failed"));
|
||||
assert.ok(html.includes(appUpdaterCheckFailureMessage("network", lang)));
|
||||
const retry = buttons(html).find((button) => button.includes(zh ? "重试在线检查" : "Retry online check"));
|
||||
assert.ok(retry);
|
||||
assert.ok(retry.includes("ui-button--primary"));
|
||||
const download = buttons(html).find((button) => button.includes(zh ? "打开下载页(备用)" : "Open download page (fallback)"));
|
||||
assert.ok(download);
|
||||
assert.ok(download.includes("ui-button--secondary"));
|
||||
assert.ok(!html.includes(zh ? "立即更新" : "Update now"), "a callback alone cannot expose installation after a failed check");
|
||||
assert.ok(!html.includes(zh ? "可前往下载页获取对应平台" : "A new version is available from the download page"));
|
||||
});
|
||||
|
||||
test(`check error details remain fixed safe explanations even for an older error state (${lang})`, async () => {
|
||||
const state = { ...INITIAL_APP_UPDATER_STATE, phase: "error", failure: "check", checkFailure: undefined, errorMessage: "error at https://private.fixture.test?token=private-secret Authorization: Bearer private-secret" };
|
||||
const html = await render({ ...props(lang), state, onRetry: noop });
|
||||
assert.ok(html.includes(appUpdaterCheckFailureMessage("unknown", lang)));
|
||||
for (const secret of ["private-secret", "private.fixture.test", "Authorization", "Bearer"]) assert.ok(!html.includes(secret));
|
||||
assert.ok(html.includes('role="alert"'));
|
||||
});
|
||||
|
||||
test(`portable manual-download dialog remains available without native updater state (${lang})`, async () => {
|
||||
const html = await render(props(lang));
|
||||
assert.ok(html.includes(zh ? "发现新版本" : "New version available"));
|
||||
assert.ok(html.includes(zh ? "检测到新版本,可前往下载页" : "A new version is available from the download page"));
|
||||
const download = buttons(html).find((button) => button.includes(zh ? "打开下载页" : "Open download page"));
|
||||
assert.ok(download?.includes("ui-button--primary"));
|
||||
assert.ok(!html.includes(zh ? "重试在线检查" : "Retry online check"));
|
||||
assert.ok(!html.includes(zh ? "立即更新" : "Update now"));
|
||||
assert.ok(!html.includes(zh ? "备用" : "fallback"));
|
||||
assert.ok(html.includes("0.3.21"));
|
||||
assert.ok(html.includes("0.3.24"));
|
||||
});
|
||||
|
||||
test(`a successfully checked native update still offers installation (${lang})`, async () => {
|
||||
const state = { ...INITIAL_APP_UPDATER_STATE, phase: "available", currentVersion: "0.3.21", latestVersion: "0.3.24" };
|
||||
const html = await render({ ...props(lang), state, onUpdate: noop, onRetry: noop });
|
||||
const install = buttons(html).find((button) => button.includes(zh ? "立即更新" : "Update now"));
|
||||
assert.ok(install?.includes("ui-button--primary"));
|
||||
assert.ok(!html.includes(zh ? "在线更新检查失败" : "Online update check failed"));
|
||||
});
|
||||
|
||||
test(`native retry uses its fresh version and cannot remain a stale fallback download dialog (${lang})`, async () => {
|
||||
const state = { ...INITIAL_APP_UPDATER_STATE, phase: "available", currentVersion: "0.3.21", latestVersion: "0.3.24" };
|
||||
const html = await render({ ...props(lang), latestVersion: "v0.3.23", state, onUpdate: noop, onRetry: noop });
|
||||
assert.ok(html.includes("0.3.24"));
|
||||
assert.ok(!html.includes("0.3.23"));
|
||||
assert.ok(buttons(html).some((button) => button.includes(zh ? "立即更新" : "Update now")));
|
||||
});
|
||||
|
||||
test(`a native retry reporting no update renders up-to-date without installation or download-only actions (${lang})`, async () => {
|
||||
const state = { ...INITIAL_APP_UPDATER_STATE, phase: "idle", currentVersion: "0.3.24" };
|
||||
const html = await render({ ...props(lang), state, onUpdate: noop, onRetry: noop });
|
||||
assert.ok(html.includes(zh ? "当前已是最新版本" : "Codex-X is up to date"));
|
||||
assert.ok(!buttons(html).some((button) => button.includes(zh ? "立即更新" : "Update now")));
|
||||
assert.ok(!buttons(html).some((button) => button.includes(zh ? "打开下载页" : "Open download page")));
|
||||
});
|
||||
|
||||
test(`download, verification, preparation, installation and restart errors keep existing copy (${lang})`, async () => {
|
||||
for (const failure of ["download", "verify", "prepare", "install", "restart"]) {
|
||||
const state = { ...INITIAL_APP_UPDATER_STATE, phase: "error", failure, errorMessage: "Fixture installation failure", logPath: "/fixtures/update.log" };
|
||||
const html = await render({ ...props(lang), state, onRetry: noop, onUpdate: noop });
|
||||
assert.ok(html.includes(zh ? "更新没有完成" : "Update did not finish"));
|
||||
assert.ok(html.includes("Fixture installation failure"));
|
||||
assert.ok(html.includes("/fixtures/update.log"));
|
||||
assert.ok(buttons(html).some((button) => button.includes(zh ? "重试" : "Try again")));
|
||||
assert.ok(buttons(html).some((button) => button.includes(zh ? "打开下载页" : "Open download page")));
|
||||
assert.ok(!html.includes(zh ? "重试在线检查" : "Retry online check"));
|
||||
assert.ok(!html.includes(zh ? "(备用)" : "(fallback)"));
|
||||
if (failure === "restart") assert.ok(html.includes(zh ? "软件未能重新启动" : "Codex-X could not restart"));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,57 @@
|
||||
|
||||
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
|
||||
|
||||
## 2026-10-09:在线更新主通道故障后的自动恢复
|
||||
|
||||
- 用户确认 Windows EXE 安装版 0.3.21 能发现新版,但只提供下载页。代码核对显示原生检查失败后仍从版本元数据服务发现新版,却把安装版更新方式静默改为 download。0.3.21 / 0.3.24 的更新模块无代码差异;公开清单平台、URL、公钥和安装包签名核验通过,Git 邮箱改写没有改变发行资产。用户机器的首通道失败原因未在现场复现,不能只凭提示判断网络、代理或便携标识。
|
||||
- 改为真正的原生在线恢复:主清单检查失败自动读取官方 GitHub API 的 latest.json 资产,仍由 SDK 检查版本/平台并创建 WebView 所有的资源;下载传输失败自动从同仓库/同 release/同文件的官方 API 资产重取,原 SDK 公钥及签名检查保持,只有验签成功的字节进入 Windows 安装暂存。跨源清空自定义认证头,使用 HTTP/1 和原有系统/显式代理;仅版本 badge 不提供安装依据。
|
||||
- 主通道和备用通道全部失败时显示安全原因和在线重试,下载页仅作备用;不再将安装版静默降为手动更新,不把失败检查称为最新。重复下载进度重新计算,不重复启动安装器。
|
||||
- 修正此前本地协议转换的退出/更新限制:保留本地地址和恢复记录,正常退出及更新后重开恢复同一协议;安装期间冻结配置并保持服务,交接失败恢复。安装中的普通 Quit 被延后,Windows 安装器交接后允许授权退出;Mac blocking 任务中断也解除冻结,已安装后的恢复警告不触发第二次安装。
|
||||
- 761 项 release/串行 Rust、154 项 Node、类型及 renderer 构建通过。真实 SDK 测试模拟 0.3.21,使用严格 HTTPS 本地服务和测试专用公钥:主检查 503 → 官方 API 清单 → SDK check → 主包 503 → API 包 → SDK 验签 → Windows 私有暂存通过;改动包字节被 SDK 拒绝。测试不运行安装器,不改真实配置/认证/会话。公开 0.3.24 EXE 的匿名 API 下载和旧公钥静态验签另行通过。没有实际在用户 Windows 上执行在线安装。
|
||||
- 修复仍在本地分支,尚未发布。旧 0.3.21 只有编译在程序中的 GitHub 主入口,不能通过修改新源码直接让旧二进制自动新增 API 通道;需要原入口恢复后升级,或一次官方安装引导后获得新通道。后续 Windows runner 打包/原生安装验证与正式发布须完成现有审阅流程。
|
||||
|
||||
## 2026-10-08:Headers 预设与填写提示补齐
|
||||
|
||||
- 用户指出供应商 Headers 缺少可选择预设,通用 `Codex-X` 值示例容易误导。复核 Issue #53 原文仅要求 Header 设置;CC Switch 的 Codex 界面使用独立 UA 控件及 `meta.customUserAgent`,通用 `X-Title` / `CC Switch` 示例来自 OpenCode / Pi 编辑器。
|
||||
- 新增用户显式选择的 Claude Code、Kilo Code `User-Agent` 预设;只更新一个 UA 行,优先复用空白草稿,不覆盖其他请求头/凭据/环境变量,重复行继续显示校验错误。不自动配置客户端身份,不生成认证凭据。
|
||||
- 填写提示按 UA、应用网址、应用名称、认证及其他 Header 区分;占位值不保存或发送。保留通用 Headers 和固定/环境变量设置,直连及路由发送行为保持原有实现。
|
||||
- 新增 9 项预设逻辑测试、中英文实际组件预设/占位渲染测试;130 项前端测试、类型检查和 renderer 构建通过。本次未修改后端、未请求真实上游。
|
||||
- macOS release 本机打包、签名/二进制摘要校验及旧 App ZIP 备份通过,已更新 Applications 中的安装。当前窗口存在未保存的供应商编辑,因此保留原进程和草稿;用户结束编辑并重开后使用新预设。构建 App 副本经校验归档,未推送 GitHub。
|
||||
|
||||
## 2026-10-08:供应商协议转换、配置定位和 Headers(待本地验收)
|
||||
|
||||
- Issue #68:原路由仅透传 Responses;新增独立上游协议元数据及 Chat Completions、Claude Messages、Gemini 原生 JSON/SSE 转换。Codex TOML 始终使用 Responses,DB v8→v9 新增可空协议列,旧记录保留,历史 Chat 配置兼容转换。CC Switch 的显式协议按元数据导入,模型获取与连接检测使用所选协议和供应商 Headers。
|
||||
- 转换包含图片、函数/命名空间/custom 工具及其结果、结构化输出、推理回放和 Gemini 签名。回归覆盖跨块 UTF-8/CRLF、工具媒体顺序、空 added 初值、单一终止事件及首个输出前后故障转移。Gemini 禁止并行工具时先检查调用数量,违规调用不会交给 Codex 执行。
|
||||
- 转换供应商需要运行中的路由和配置接管。候选配置提前校验,重接管失败恢复配置、认证、选择和原运行路由;不能恢复成无效的原生直连。关闭路由或退出前需切回 Responses/官方,异常退出后重新启动本地路由。安装更新前也使用只读校验,转换路由阻止安装时明确给出切换建议。
|
||||
- Issue #67:配置问题展示文件、原始行列、参数和修正建议;缺失参数不伪造行号。当前官方登录与路由证据充分时补齐旧会话缺失的官方别名;先备份,保留 fingerprint、外部写入和认证变动保护,不改会话日志或索引。
|
||||
- Issue #53:新增固定/环境变量 Headers 编辑与逐行校验,保留其他 TOML 字段及注释。异步读取/写入丢弃过期结果,未完成或无效草稿不能保存。该项独立快照通过 159 项供应商 Rust 测试、16 项 Node 测试和类型检查。
|
||||
- 全批按既有 CI 的 release + 串行设置运行 744 项 Rust 测试,全部通过;119 项 Node 测试、类型检查、renderer 构建及格式/差异检查通过。并行 debug 全套曾遇测试数据库锁争用,后续验证使用仓库已有串行规则;没有通过修改生产锁策略掩盖测试争用。
|
||||
- 已生成 macOS arm64 debug 测试包,随后按用户要求以 release 重新打包,校验 ad-hoc 签名及二进制摘要,验证旧安装 ZIP 备份后替换 `/Applications/Codex-X.app` 并启动;原生概览正常加载。构建副本经 ZIP/摘要验证后归档并注销重复注册,保留 Applications 中的安装。版本号暂沿用 0.3.24,未做 Apple 公证或真实上游联网验收。
|
||||
- 测试使用临时配置/SQLite/会话和本地模拟 HTTP/SSE,不访问真实模型上游,不改真实会话日志;原生供应商的实际网络兼容性仍需用户本地验收。转换范围与明确拒绝的能力见[供应商协议说明](PROVIDER_PROTOCOLS.md)。本批尚未推送、发布或关闭 Issues。
|
||||
|
||||
## 2026-09-30:图片输入、模型展示与供应商卡片排序(本地验证完成)
|
||||
|
||||
- Issues #63/#65 的图片拦截来自自定义模型目录的 `input_modalities = [text]`。
|
||||
生成目录现允许 `text` 与 `image`,保留真实模型 ID;供应商是否支持图片仍由其接口决定。
|
||||
独立修复命令只升级路径、所有权标记与摘要均验证通过的旧 Codex-X 目录,生成不可变新文件,
|
||||
备份并条件写入配置指针;认证、缓存、其他配置和旧目录保持可恢复。状态读取接口继续只读。
|
||||
- Issue #64 的新目录原本已包含 Ultra;Codex GUI 还由“模型选择器滑块中的 Ultra 选项”及多代理设置控制。
|
||||
补充客户端提示和旧能力目录迁移,不强制修改 Codex 的偏好。Codex CLI 0.159 读取目录与本地 Responses
|
||||
模拟请求验证通过:实际包含图片输入,Ultra 正常映射为 API 的 Max,turn 完成,没有真实供应商请求。
|
||||
- Issue #62 支持官方、已保存和检测卡片跨来源拖动;Pointer Capture 避免 WebView 原生拖放冲突,
|
||||
支持键盘上下移动、边缘连续滚动和列表外取消。顺序按配置目录独立保存到 SQLite v8 偏好表;
|
||||
保存期间阻止冲突刷新,失败恢复原顺序,新卡追加,缺席卡忽略。
|
||||
- Issue #61 卡片显示模型,映射名称与真实 ID 同时保留;当前第三方卡使用实时配置模型,官方卡优先使用
|
||||
官方登录监控读到的模型。新增显式 `CODEXX_CONFIG_DIR`,配合已有 `CODEXX_HOME` 隔离原生验证数据。
|
||||
- 618 项 Rust、64 项前端测试、类型检查通过;浏览器验证明暗布局、上下拖动、列表外取消、别名与 ID 展示、
|
||||
重载持久化、延迟保存及失败回滚。本地 `.app` 已构建、备份后替换安装,并通过本地签名与二进制摘要校验。
|
||||
已安装 App 的旧目录迁移和独立数据库启动通过;原生 App 验证模型别名及 ID、跨来源上下拖动、SQLite
|
||||
写入失败回滚和退出重启后的顺序保留,明暗主题显示正常。测试使用独立配置和数据库,真实配置/认证摘要一致。
|
||||
- 用户已批准将本批修改作为 v0.3.22 推送,并由既有 Actions 完成跨平台打包、验签与发布。
|
||||
- [v0.3.22 发布流水线](https://github.com/yynxxxxx/Codex-X/actions/runs/36705339645) 的四平台与最终发布验证全部成功;
|
||||
Windows 全量 Rust 及 NSIS/旧 MSI 安装迁移通过,两架构 macOS 签名、公证与 DMG 校验通过。
|
||||
公开更新清单的 11 个平台项验证完整,正式 Apple Silicon 更新包摘要、签名与公证票据核对通过,本地已换成正式 v0.3.22。
|
||||
|
||||
## 2026-09-22:Windows 更新改为用户安装并支持无重启迁移
|
||||
|
||||
- Windows 改用 NSIS 当前用户安装,旧 MSI 首次迁移只提升系统卸载子进程;准确核对旧产品身份、卸载结果和目录隔离,避免第二份安装。不因 MSI 返回 3010 一律要求重启;旧产品登记已移除、新目录安全隔离时继续使用新版,旧目录残留交给 Windows 后续清理。
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 供应商协议与 Headers
|
||||
|
||||
## 选择上游协议
|
||||
|
||||
在「供应商 → 新增 / 编辑」选择上游实际提供的协议:
|
||||
|
||||
| 上游接口协议 | 上游请求 | 默认认证 | 需要本地路由 |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenAI Responses | `/v1/responses` | Bearer API Key | 否 |
|
||||
| OpenAI Chat Completions | `/v1/chat/completions` | Bearer API Key | 是 |
|
||||
| Claude Messages | `/v1/messages` | `x-api-key`、`anthropic-version` | 是 |
|
||||
| Gemini generateContent | `/v1beta/models/{model}:generateContent` 或 `:streamGenerateContent` | `x-goog-api-key` | 是 |
|
||||
|
||||
Base URL 可以包含服务的路径前缀。Gemini 地址已有 `/v1` 或 `/v1beta` 时使用该版本,不重复添加。
|
||||
模型名称必须是该供应商支持的名称;获取模型列表和连接检测也使用所选协议及 Headers。
|
||||
|
||||
Chat、Claude、Gemini 供应商可以先保存,再到「设置 → 路由与故障转移」开启路由总开关和 Codex 请求接管,随后启用供应商。
|
||||
Codex 始终使用 Responses,本地路由负责转换请求及普通 / 流式响应。自动切换是独立选项,不是使用协议转换的前提。
|
||||
|
||||
协议转换期间保持 Codex-X 运行。关闭路由或取消接管前,请先切回 Responses 供应商或官方账号。退出期间 Codex 请求会暂停,重开 Codex-X 后自动恢复本地转换路由。在线更新会保留转换协议和恢复记录,安装后重开恢复;无需为了更新强制切换供应商。
|
||||
应用不会将 Claude / Gemini 原生地址恢复成不能使用的 Responses 直连地址;异常退出后会按保存的配置重新启动路由。
|
||||
端口被占用或配置损坏时会明确提示,需要修复路由或切换供应商。
|
||||
|
||||
上游协议保存在 Codex-X 的供应商记录中;生成的 Codex TOML 使用 `wire_api = "responses"`。
|
||||
旧的 Chat 配置会识别为 Chat Completions 转换,复制供应商和再次导入保留协议选择。
|
||||
|
||||
## 转换范围
|
||||
|
||||
- 转换文字、图片、函数工具、命名空间工具及 custom 工具(包括 `apply_patch`)的输入输出。
|
||||
- 支持 JSON 与 SSE 响应、工具参数增量、用量及结束状态;已向 Codex 输出内容后不会重放到其他供应商。
|
||||
- Claude 的思考预算受 `max_output_tokens` 和上游模型能力约束。Gemini 3 使用相应模型支持的 `thinkingLevel`;其他 Gemini 模型的非 `none` 强度使用上游动态思考预算,强度不是与 OpenAI 一一对应的保证。
|
||||
- Gemini 思考签名跟随对应模型消息回放;不能把其他协议的加密推理状态当成 Gemini 签名。
|
||||
- 不支持的参数、媒体形式或上游返回内容会报告具体兼容问题,不会静默丢弃后显示成功。Gemini 不接受无法原生保证的 `strict = true` 工具声明;普通 Codex 内置 shell 工具使用 `strict = false`。
|
||||
- Claude / Gemini 原生图片输入支持 `detail = auto`,不接受无法对应的 `high` / `low` 图像精度参数。本地 base64 图片直接转换;远程文件 URI 由上游检验,路由不会擅自下载文件。
|
||||
- Claude 未指定输出上限时使用 8192 tokens。Gemini 禁止并行工具的请求会在本地检查;上游违规返回多个调用时明确报错,不会执行多个或丢弃其中一个。
|
||||
|
||||
转换协议不提供 Responses 的服务端会话状态、后台任务、托管工具或 `/compact` 接口。
|
||||
带 `previous_response_id`、不兼容的加密状态等请求需要完整对话、新建会话或使用原生 Responses 供应商。
|
||||
上游仍可能拒绝不支持的模型、工具或采样组合;选择协议不会增加模型本身的能力。
|
||||
|
||||
## 编辑 Headers
|
||||
|
||||
在供应商表单中添加固定值或环境变量来源的 Header。
|
||||
|
||||
`User-Agent` 用于客户端标识,可手动填写或明确选择 Claude Code / Kilo Code 预设。预设只添加或更新一个 `User-Agent` 固定值,其他请求头保留;不选预设就保持现有设置。预设值来自本次核对的 CC Switch 配置,不保证每家供应商均接受,也不会填入任何 API Key。
|
||||
|
||||
填写示例随名称变化:`HTTP-Referer` 填应用网址,`X-Title` / `X-OpenRouter-Title` 填应用名称,认证头按供应商文档填写。`X-Title: Codex-X` 表示应用名称,是合法的可选标识;它不承担 User-Agent 客户端校验的作用。占位提示不会作为实际值保存或发送。
|
||||
|
||||
CC Switch 的 Codex 页面将 UA 保存在 `meta.customUserAgent`,日常转发通过本地路由生效;其通用 Headers 键值编辑器主要用于 OpenCode / Pi。Codex-X 将这些字段保存到 Codex 原生 `http_headers` / `env_http_headers`:直连由 Codex 发送,路由模式由 Codex-X 发送。
|
||||
同一个名称不能重复(忽略大小写),字段错误直接显示在对应行。固定值保留原有空格。
|
||||
环境变量模式仅保存变量名:直连时由 Codex 读取,经过路由时由 Codex-X 读取,请在启动应用前设置变量。
|
||||
|
||||
直连使用 `Authorization` Header 认证时请留空 API Key;显式 API Key 会替换旧的 Authorization。
|
||||
本地路由使用当前上游供应商自己的认证和 Headers,供应商的显式认证 Header 优先于默认认证。
|
||||
本地路由不允许自定义 Host、Cookie、连接控制头或内部路由认证头,不会把 Codex 的本地路由令牌发送给上游。
|
||||
|
||||
Headers 保存在已有的 TOML `http_headers` / `env_http_headers` 字段;编辑会保留其他 TOML 字段及注释。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
本说明记录 Codex-X 本轮路由实现与 CC Switch 的对应关系,供维护者检查行为和后续回归使用。
|
||||
入口为「设置 → 路由与故障转移」。本轮对齐 Codex 的监听、接管、优先队列、请求重试、熔断和
|
||||
原生官方账号路由;不等于移植 CC Switch 的全部应用适配器和请求转换功能。
|
||||
原生官方账号路由。新增 Chat Completions、Claude Messages 与 Gemini 上游转换见[供应商协议说明](PROVIDER_PROTOCOLS.md)。
|
||||
|
||||
参考版本固定为 CC Switch 提交
|
||||
[`06082e189d65e6d6dbadc35dacdac1ce6c79d89a`](https://github.com/farion1231/cc-switch/tree/06082e189d65e6d6dbadc35dacdac1ce6c79d89a)
|
||||
@@ -19,7 +19,7 @@
|
||||
| `autoFailoverEnabled` | `false` | 对第三方 API 请求使用完整优先队列;关闭时只使用当前供应商。 |
|
||||
|
||||
- 首次开启自动故障转移要求服务与接管均已开启。保存设置、让开关从关变为开时立即启用队列 P1。
|
||||
- 关闭接管会先恢复直连,监听服务可以继续运行。关闭路由总开关会恢复直连并关闭接管,
|
||||
- 原生 Responses 供应商关闭接管会先恢复直连,监听服务可以继续运行。关闭路由总开关会恢复直连并关闭接管,
|
||||
但保留自动故障转移偏好、队列和参数。
|
||||
- 官方登录使用独立的原生官方路由,自动队列暂不参与;这不要求删除原有第三方队列。
|
||||
- 手动切换第三方供应商、编辑当前配置、切换官方账号时,完整原操作包在
|
||||
@@ -176,10 +176,11 @@ HTTP 400–599 中,以下状态不进入候补重试:`400/405/406/413/414/41
|
||||
|
||||
## 适用边界与实现差异
|
||||
|
||||
- 当前数据面提供 `POST /v1/responses`、`POST /v1/responses/compact`、`GET /v1/models`,使用 HTTP/SSE,
|
||||
- 当前数据面提供 `POST /v1/responses`、`GET /v1/models`;原生 Responses 上游另支持 `POST /v1/responses/compact`。使用 HTTP/SSE,
|
||||
不提供 WebSocket 升级或任意 URL 转发。
|
||||
- 第三方接口需兼容 Responses。没有 Chat Completions/Anthropic 协议转换、请求整流器、图片降级、
|
||||
Claude/Gemini/Grok 应用接管或跨应用配置管理;不能把这些上游能力写成本项目已具备。
|
||||
- 原生 Responses 直接转发,Chat Completions/Claude Messages/Gemini 原生接口通过本地路由转换。
|
||||
转换接口不支持 Responses 的服务端状态与 `/compact`;具体范围见[供应商协议说明](PROVIDER_PROTOCOLS.md)。
|
||||
不提供 Claude/Gemini/Grok 应用接管或跨应用配置管理。
|
||||
- 队列不限制模型名称,但不保证任意第三方都支持请求中的模型、工具或服务端状态。
|
||||
厂商专用 `query_params` 目前不支持自动路由;无效认证、缺失环境变量或不适用的认证头会报告原因。
|
||||
- 本地监听即使绑定非回环地址,也保留随机令牌、Host 和来源检查。不是无需认证的公开代理。
|
||||
|
||||
+14
-4
@@ -4,12 +4,14 @@ Windows 新安装包使用 NSIS EXE,安装到原登录用户的本地应用目
|
||||
|
||||
## 更新交接
|
||||
|
||||
- 插件 `check` 返回的 `Update` 资源仍负责下载与签名验证。自定义 `install_app_update` 只接受本 WebView 的资源 ID,不能指定任意安装路径或未验签字节。
|
||||
- 下载使用独立的 10 分钟总超时,不再继承版本检查的 15 秒超时。前端显示下载、验签、准备退出、启动安装、已交给安装器;30 秒无进展提供提示,但不并发重试或强杀安装器。
|
||||
- Windows 先把已验签 EXE 写入本次私有临时目录,再恢复接管配置和停止本地监听。准备失败不启动安装;启动失败恢复保存的路由,恢复失败保留偏好并如实提示。
|
||||
- `check_online_app_update` 通过原生 SDK 创建更新资源,保留原有版本、平台、公钥、TLS 和签名检查。`install_app_update` 只接受本 WebView 管理的 SDK 资源,不能指定任意安装路径或未验签字节。
|
||||
- 主更新检查失败时自动通过本仓库官方 GitHub API 的 `latest.json` 资产端点继续 SDK 检查;使用 HTTP/1 请求及原有系统/显式代理。备用 API 返回的是完整更新清单,不是仅含版本号的 badge。
|
||||
- 主安装包下载出现传输失败时,自动查找同仓库、同 release、同文件的官方 API 资产地址重新下载;备用下载仍由原 SDK 验签,签名失败不会启动安装。跨源不携带自定义认证头。
|
||||
- 下载使用独立的 10 分钟总超时,不再继承版本检查的 30 秒超时。前端显示下载、验签、准备退出、启动安装、已交给安装器;30 秒无进展提供提示,但不并发重试或强杀安装器。
|
||||
- Windows 先把已验签 EXE 写入本次私有临时目录,再完成路由交接并停止本地监听。原生 Responses 恢复直连;转换协议保留本地地址及恢复记录,安装后新进程恢复同一协议。准备失败不启动安装;启动失败恢复保存的路由,恢复失败保留偏好并如实提示。
|
||||
- 启动安装器使用原生 `Command` 参数,随后通过正常 Tauri 退出事件释放窗口、托盘和单实例锁。安装器通过 `/CODEXXPID` 句柄等待旧进程退出;30 秒未退出则停止本次安装,不杀进程、不复制新文件。
|
||||
- “已交给安装器”只表示成功启动,不等于更新已完成。后续 IPC 断开不能触发第二个安装进程。
|
||||
- macOS/Linux 继续使用插件原有安装逻辑,安装成功后才进入等待重启状态。
|
||||
- macOS/Linux 继续使用插件原有安装逻辑,安装期间冻结配置变更但保留监听,失败恢复原路由;安装成功后解除冻结,随后重启时恢复转换协议。
|
||||
|
||||
## 兼容已发布的 MSI
|
||||
|
||||
@@ -29,6 +31,14 @@ Windows 新安装包使用 NSIS EXE,安装到原登录用户的本地应用目
|
||||
|
||||
官网本地下载选择器已支持优先 EXE、回退旧 MSI;官网是独立部署,软件正式发布前须同步该兼容修改。便携版仍使用手动更新。
|
||||
|
||||
## 0.3.21 安装版只显示下载页的排查
|
||||
|
||||
已发布 0.3.21 的 Windows 安装包已是 NSIS EXE。其原生更新检查失败后会继续用 Shields / GitHub Release API 查版本,却将 `updateMethod` 改为 `download`,从而隐藏原生失败原因和在线安装入口。本轮改为先自动恢复原生通道;全部通道均失败才显示原因及在线重试,下载页只是备用。
|
||||
|
||||
0.3.21 / 0.3.24 公钥及更新器版本相同,当前公开 `latest.json` 的三个 Windows 平台键及签名均有效;使用旧公钥对当前 EXE 静态验签通过。改写 Git 提交邮箱不会改变这些资产。该证据不等于已复现用户机器的网络故障。
|
||||
|
||||
旧客户端的检查入口已经编译在程序中,新通道需要客户端升级后生效。若旧版无法访问唯一的 GitHub 入口,不能仅通过改新源码远程替换它;应先恢复其 GitHub/代理连接完成一次原生更新,或使用官方安装包完成首次引导。Windows 程序目录旁的 `Codex-X.portable` 标识也会将旧版识别为便携版,需结合实际安装目录确认,不把正常便携版强制变成安装版。
|
||||
|
||||
## 验证
|
||||
|
||||
- 本机 Rust 回归覆盖安装交接、失败恢复、端口冲突、签名失败阶段、并发更新保护和安装包暂存;前端覆盖各阶段、迟到事件、断连和长等待。
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.26",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#requires -Version 7.0
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CargoJson,
|
||||
[string]$OutputDirectory = [System.IO.Path]::GetTempPath()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
|
||||
throw 'This helper only runs on the Windows CI runner.'
|
||||
}
|
||||
if ($env:GITHUB_ACTIONS -ne 'true' -or -not $env:RUNNER_TEMP) {
|
||||
throw 'This helper is restricted to an ephemeral Windows Actions runner.'
|
||||
}
|
||||
|
||||
# Cargo, not a filesystem wildcard or a caller-supplied EXE, selects the target.
|
||||
$executables = @(Get-Content -LiteralPath $CargoJson | ForEach-Object {
|
||||
if (-not [string]::IsNullOrWhiteSpace($_)) {
|
||||
$record = ConvertFrom-Json -InputObject $_ -AsHashtable
|
||||
if ($record['reason'] -eq 'compiler-artifact' -and
|
||||
$record['target']['name'] -eq 'codexx_lib' -and
|
||||
$record['profile']['test'] -eq $true -and
|
||||
$record['executable'] -is [string]) {
|
||||
[System.IO.Path]::GetFullPath($record['executable'])
|
||||
}
|
||||
}
|
||||
} | Sort-Object -Unique)
|
||||
if ($executables.Count -ne 1) {
|
||||
throw "Expected one codexx_lib test executable in Cargo JSON; found $($executables.Count)."
|
||||
}
|
||||
$testExecutable = (Resolve-Path -LiteralPath $executables[0]).Path
|
||||
$testFile = Get-Item -LiteralPath $testExecutable
|
||||
if ($testFile.Name -notmatch '^codexx_lib-[0-9a-f]{8,32}\.exe$' -or
|
||||
$testFile.Directory.Name -ne 'deps' -or
|
||||
$testFile.Directory.Parent.Name -notin @('release', 'debug')) {
|
||||
throw "Refusing to modify a production or non-harness executable: $testExecutable"
|
||||
}
|
||||
|
||||
$sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
|
||||
$manifestTool = @(Get-ChildItem -LiteralPath $sdkRoot -Directory |
|
||||
Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } |
|
||||
Sort-Object { [version]$_.Name } -Descending |
|
||||
ForEach-Object { Join-Path $_.FullName 'x64\mt.exe' } |
|
||||
Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
|
||||
Select-Object -First 1)
|
||||
if ($manifestTool.Count -ne 1) { throw 'Windows SDK x64 mt.exe was not found.' }
|
||||
$manifestTool = $manifestTool[0]
|
||||
|
||||
function Invoke-ManifestTool([string[]]$ToolArguments) {
|
||||
$start = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$start.FileName = $manifestTool
|
||||
$start.UseShellExecute = $false
|
||||
$start.RedirectStandardOutput = $true
|
||||
$start.RedirectStandardError = $true
|
||||
foreach ($argument in $ToolArguments) { $start.ArgumentList.Add($argument) }
|
||||
$manifestProcess = [System.Diagnostics.Process]::new()
|
||||
$manifestProcess.StartInfo = $start
|
||||
try {
|
||||
if (-not $manifestProcess.Start()) { throw 'Unable to start SDK mt.exe.' }
|
||||
$stdout = $manifestProcess.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $manifestProcess.StandardError.ReadToEndAsync()
|
||||
$manifestProcess.WaitForExit()
|
||||
return @{ Code = $manifestProcess.ExitCode; Text = $stdout.Result + $stderr.Result }
|
||||
} finally { $manifestProcess.Dispose() }
|
||||
}
|
||||
function Require-ManifestTool([string[]]$ToolArguments) {
|
||||
$result = Invoke-ManifestTool $ToolArguments
|
||||
if ($result.Code -ne 0) { throw "mt.exe failed ($($result.Code)): $($result.Text)" }
|
||||
}
|
||||
|
||||
$reviewDirectory = Join-Path $OutputDirectory ('codexx-test-manifest-' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $reviewDirectory | Out-Null
|
||||
$beforeManifest = Join-Path $reviewDirectory 'before.manifest'
|
||||
$additionManifest = Join-Path $reviewDirectory 'tauri-common-controls.manifest'
|
||||
$mergedManifest = Join-Path $reviewDirectory 'merged.manifest'
|
||||
$extractedManifest = Join-Path $reviewDirectory 'embedded.manifest'
|
||||
$backupExecutable = Join-Path $reviewDirectory ($testFile.Name + '.before')
|
||||
Copy-Item -LiteralPath $testExecutable -Destination $backupExecutable
|
||||
|
||||
$probe = Invoke-ManifestTool @('-nologo', "-inputresource:$testExecutable;#1", "-out:$beforeManifest")
|
||||
if ($probe.Code -eq 0) {
|
||||
$inputs = @($beforeManifest, $additionManifest)
|
||||
} elseif ($probe.Text -match '(?i)(The specified resource (type|name|language).*cannot be found|The specified image file.*did not contain a resource section)') {
|
||||
$inputs = @($additionManifest)
|
||||
} else {
|
||||
throw "Cannot safely extract the existing harness manifest: $($probe.Text)"
|
||||
}
|
||||
[System.IO.File]::WriteAllText((Join-Path $reviewDirectory 'extract-before.log'), $probe.Text)
|
||||
|
||||
$hasDefinitionIdentity = $false
|
||||
if ($probe.Code -eq 0) {
|
||||
[xml]$before = Get-Content -LiteralPath $beforeManifest -Raw
|
||||
$hasDefinitionIdentity = $null -ne $before.SelectSingleNode("/*[local-name()='assembly']/*[local-name()='assemblyIdentity']")
|
||||
}
|
||||
|
||||
# Same dependency tuple as tauri-build's default windows-app-manifest.xml.
|
||||
$addition = @'
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
'@
|
||||
[System.IO.File]::WriteAllText($additionManifest, $addition, [System.Text.UTF8Encoding]::new($false))
|
||||
if (-not $hasDefinitionIdentity) {
|
||||
[xml]$definition = $addition
|
||||
$identity = $definition.CreateElement('assemblyIdentity', $definition.DocumentElement.NamespaceURI)
|
||||
$identity.SetAttribute('type', 'win32')
|
||||
$identity.SetAttribute('name', 'Codex-X.TestHarness')
|
||||
$identity.SetAttribute('version', '1.0.0.0')
|
||||
$identity.SetAttribute('processorArchitecture', 'amd64')
|
||||
[void]$definition.DocumentElement.PrependChild($identity)
|
||||
[System.IO.File]::WriteAllText($additionManifest, $definition.OuterXml, [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
Require-ManifestTool (@('-nologo', '-manifest') + $inputs + @("-out:$mergedManifest"))
|
||||
Require-ManifestTool @('-nologo', '-manifest', $mergedManifest, '-validate_manifest')
|
||||
Require-ManifestTool @('-nologo', '-manifest', $mergedManifest, "-outputresource:$testExecutable;#1")
|
||||
Require-ManifestTool @('-nologo', "-inputresource:$testExecutable;#1", "-out:$extractedManifest")
|
||||
[xml]$embedded = Get-Content -LiteralPath $extractedManifest -Raw
|
||||
$identity = $embedded.SelectSingleNode("//*[local-name()='dependency']/*[local-name()='dependentAssembly']/*[local-name()='assemblyIdentity'][@name='Microsoft.Windows.Common-Controls' and @version='6.0.0.0' and @publicKeyToken='6595b64144ccf1df']")
|
||||
if ($null -eq $identity) { throw 'Extracted harness manifest lacks the Common Controls v6 dependency.' }
|
||||
if ($null -eq $embedded.SelectSingleNode("/*[local-name()='assembly']/*[local-name()='assemblyIdentity']")) {
|
||||
throw 'Extracted harness manifest lacks its definition identity.'
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
testExecutable = $testExecutable
|
||||
manifestTool = $manifestTool
|
||||
hadExistingManifest = ($probe.Code -eq 0)
|
||||
originalExecutableBackup = $backupExecutable
|
||||
extractedManifest = $extractedManifest
|
||||
commonControlsV6Verified = $true
|
||||
testsExecutedByHelper = $false
|
||||
} | ConvertTo-Json
|
||||
@@ -0,0 +1,289 @@
|
||||
# Read-only loader diagnostics for an ephemeral Windows Actions runner.
|
||||
# Reads the test PE import/export tables; never invokes imported functions or
|
||||
# DLL DllMain. Optional --list starts only the Rust test harness, no test bodies.
|
||||
param(
|
||||
[string]$TestExe,
|
||||
[string]$OutputDirectory,
|
||||
[switch]$RunHarnessList
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:GITHUB_ACTIONS -ne 'true' -or -not $env:RUNNER_TEMP) {
|
||||
throw 'This diagnostic is restricted to an ephemeral Windows Actions runner.'
|
||||
}
|
||||
if (-not $TestExe) {
|
||||
$candidate = Get-ChildItem 'apps/desktop/src-tauri/target/release/deps/codexx_lib-*.exe' |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if (-not $candidate) { throw 'Compile cargo test --release --lib --no-run first.' }
|
||||
$TestExe = $candidate.FullName
|
||||
}
|
||||
$TestExe = (Resolve-Path -LiteralPath $TestExe).Path
|
||||
if ((Split-Path -Leaf $TestExe) -notlike 'codexx_lib-*.exe') {
|
||||
throw 'Only the Codex-X Rust test harness may be inspected.'
|
||||
}
|
||||
if (-not $OutputDirectory) {
|
||||
$OutputDirectory = Join-Path $env:RUNNER_TEMP 'codexx-dll-diagnostics'
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
|
||||
$ExeDirectory = Split-Path -Parent $TestExe
|
||||
$dumpbinCommand = Get-Command dumpbin.exe -ErrorAction SilentlyContinue
|
||||
if ($dumpbinCommand) { $Dumpbin = $dumpbinCommand.Source }
|
||||
else {
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe'
|
||||
if (-not (Test-Path -LiteralPath $vswhere)) { throw 'Neither dumpbin nor vswhere was found.' }
|
||||
$vs = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | Select-Object -First 1)
|
||||
$Dumpbin = (Get-ChildItem "$vs/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe" |
|
||||
Sort-Object FullName -Descending | Select-Object -First 1).FullName
|
||||
if (-not $Dumpbin) { throw 'MSVC dumpbin.exe was not found.' }
|
||||
}
|
||||
$report = [ordered]@{
|
||||
TestExe = $TestExe
|
||||
TestSha256 = (Get-FileHash -LiteralPath $TestExe -Algorithm SHA256).Hash
|
||||
ImageOS = $env:ImageOS
|
||||
ImageVersion = $env:ImageVersion
|
||||
OS = [Environment]::OSVersion.VersionString
|
||||
Dumpbin = $Dumpbin
|
||||
Rust = ((& rustc --version --verbose) -join "`n")
|
||||
Cargo = ((& cargo --version) -join "`n")
|
||||
WorkingDirectory = (Get-Location).Path
|
||||
Path = $env:PATH
|
||||
Modules = @()
|
||||
Findings = @()
|
||||
Notes = @('DONT_RESOLVE_DLL_REFERENCES prevents DllMain and dependent imports from executing.',
|
||||
'PowerShell API-set resolution is recorded with the real module path.',
|
||||
'Candidates include test executable directory, System32, Windows and PATH; Cargo OUT_DIRs are appended for diagnostics.',
|
||||
'Missing named exports are verified with PE export tables and GetProcAddress; forwarders are followed.',
|
||||
'A finding is evidence to compare with the actual test process DLL search/activation context, not a guessed root cause.')
|
||||
}
|
||||
& $Dumpbin /imports $TestExe 2>&1 | Out-File (Join-Path $OutputDirectory 'test-imports.txt') -Encoding utf8
|
||||
& $Dumpbin /dependents $TestExe 2>&1 | Out-File (Join-Path $OutputDirectory 'test-dependents.txt') -Encoding utf8
|
||||
& $Dumpbin /headers $TestExe 2>&1 | Out-File (Join-Path $OutputDirectory 'test-headers.txt') -Encoding utf8
|
||||
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
public sealed class CodexxPeImport {
|
||||
public string Dll; public string Name; public ushort Ordinal; public bool ByOrdinal;
|
||||
}
|
||||
public sealed class CodexxPeExport { public bool Exists; public string Forwarder; }
|
||||
public static class CodexxLoaderProbe {
|
||||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
||||
public static extern IntPtr LoadLibraryExW(string name, IntPtr file, uint flags);
|
||||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
||||
public static extern uint GetModuleFileNameW(IntPtr module, StringBuilder path, int size);
|
||||
[DllImport("kernel32.dll", CharSet=CharSet.Ansi, ExactSpelling=true, SetLastError=true)]
|
||||
public static extern IntPtr GetProcAddress(IntPtr module, string name);
|
||||
[DllImport("kernel32.dll", EntryPoint="GetProcAddress", ExactSpelling=true, SetLastError=true)]
|
||||
public static extern IntPtr GetProcAddressOrdinal(IntPtr module, IntPtr ordinal);
|
||||
[DllImport("kernel32.dll")] public static extern bool FreeLibrary(IntPtr module);
|
||||
[DllImport("kernel32.dll")] public static extern uint SetErrorMode(uint mode);
|
||||
static Dictionary<string,byte[]> Files = new Dictionary<string,byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
static Dictionary<string,Dictionary<string,uint>> ExportNames = new Dictionary<string,Dictionary<string,uint>>(StringComparer.OrdinalIgnoreCase);
|
||||
static byte[] Bytes(string path) {
|
||||
byte[] value; if(Files.TryGetValue(path,out value)) return value;
|
||||
value=File.ReadAllBytes(path); Files[path]=value; return value;
|
||||
}
|
||||
static ushort U16(byte[] b,int p) { return BitConverter.ToUInt16(b,p); }
|
||||
static uint U32(byte[] b,int p) { return BitConverter.ToUInt32(b,p); }
|
||||
static ulong U64(byte[] b,int p) { return BitConverter.ToUInt64(b,p); }
|
||||
static int Optional(byte[] b) {
|
||||
if(b.Length<64 || b[0]!=0x4d || b[1]!=0x5a) throw new InvalidDataException("Not PE");
|
||||
int pe=checked((int)U32(b,60));
|
||||
if(pe+24>b.Length || U32(b,pe)!=0x4550) throw new InvalidDataException("Bad PE header");
|
||||
return pe+24;
|
||||
}
|
||||
static int Directory(byte[] b,int index) {
|
||||
int opt=Optional(b); ushort magic=U16(b,opt);
|
||||
if(magic!=0x20b && magic!=0x10b) throw new InvalidDataException("Unsupported PE");
|
||||
return opt+(magic==0x20b?112:96)+index*8;
|
||||
}
|
||||
static int Offset(byte[] b,uint rva) {
|
||||
int opt=Optional(b); int pe=opt-24;
|
||||
uint headerSize=U32(b,opt+60);
|
||||
if(rva<headerSize && rva<b.Length) return (int)rva;
|
||||
int section=opt+U16(b,pe+20), count=U16(b,pe+6);
|
||||
for(int n=0;n<count;n++,section+=40) {
|
||||
uint size=Math.Max(U32(b,section+8),U32(b,section+16));
|
||||
uint va=U32(b,section+12), raw=U32(b,section+20);
|
||||
if(rva>=va && (ulong)rva<(ulong)va+size) {
|
||||
ulong p=(ulong)raw+rva-va;
|
||||
if(p>=(ulong)b.Length) throw new InvalidDataException("RVA outside file");
|
||||
return checked((int)p);
|
||||
}
|
||||
}
|
||||
throw new InvalidDataException("Unmapped RVA");
|
||||
}
|
||||
static string Z(byte[] b,int p) {
|
||||
int end=p; while(end<b.Length && b[end]!=0 && end-p<4096) end++;
|
||||
if(end==b.Length || end-p==4096) throw new InvalidDataException("Unterminated PE string");
|
||||
return Encoding.ASCII.GetString(b,p,end-p);
|
||||
}
|
||||
public static CodexxPeImport[] Imports(string path) {
|
||||
byte[] b=Bytes(path); int dir=Directory(b,1); uint rva=U32(b,dir);
|
||||
var result=new List<CodexxPeImport>(); if(rva==0) return result.ToArray();
|
||||
int desc=Offset(b,rva); bool x64=U16(b,Optional(b))==0x20b;
|
||||
for(int d=0;d<4096;d++,desc+=20) {
|
||||
uint nameRva=U32(b,desc+12), thunkRva=U32(b,desc);
|
||||
if(nameRva==0) break; if(thunkRva==0) thunkRva=U32(b,desc+16);
|
||||
string dll=Z(b,Offset(b,nameRva)); int thunk=Offset(b,thunkRva);
|
||||
for(int n=0;n<100000;n++,thunk+=(x64?8:4)) {
|
||||
ulong v=x64?U64(b,thunk):U32(b,thunk); if(v==0) break;
|
||||
bool ordinal=(v & (x64?0x8000000000000000UL:0x80000000UL))!=0;
|
||||
result.Add(new CodexxPeImport { Dll=dll, ByOrdinal=ordinal,
|
||||
Ordinal=(ushort)(v & 65535), Name=ordinal?null:Z(b,Offset(b,checked((uint)v))+2) });
|
||||
}
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
public static CodexxPeExport Export(string path,string wanted,ushort ordinal,bool byOrdinal) {
|
||||
byte[] b=Bytes(path); int dir=Directory(b,0);
|
||||
uint rva=U32(b,dir), size=U32(b,dir+4); var result=new CodexxPeExport();
|
||||
if(rva==0) return result; int exp=Offset(b,rva);
|
||||
uint first=U32(b,exp+16), count=U32(b,exp+20), names=U32(b,exp+24);
|
||||
int funcs=Offset(b,U32(b,exp+28)); uint index=UInt32.MaxValue;
|
||||
if(byOrdinal) { if(ordinal<first || ordinal-first>=count) return result; index=ordinal-first; }
|
||||
else {
|
||||
Dictionary<string,uint> lookup;
|
||||
if(!ExportNames.TryGetValue(path,out lookup)) {
|
||||
lookup=new Dictionary<string,uint>(StringComparer.Ordinal);
|
||||
int nameTable=Offset(b,U32(b,exp+32)), ordTable=Offset(b,U32(b,exp+36));
|
||||
for(uint n=0;n<names;n++) lookup[Z(b,Offset(b,U32(b,nameTable+checked((int)n*4))))]=U16(b,ordTable+checked((int)n*2));
|
||||
ExportNames[path]=lookup;
|
||||
}
|
||||
if(!lookup.TryGetValue(wanted,out index)) return result;
|
||||
}
|
||||
if(index==UInt32.MaxValue || index>=count) return result;
|
||||
uint target=U32(b,funcs+checked((int)index*4)); if(target==0) return result;
|
||||
result.Exists=true;
|
||||
if(target>=rva && (ulong)target<(ulong)rva+size) result.Forwarder=Z(b,Offset(b,target));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$searchFolders = [Collections.Generic.List[string]]::new()
|
||||
$searchFolders.Add($ExeDirectory)
|
||||
$searchFolders.Add((Join-Path $env:SystemRoot 'System32'))
|
||||
$searchFolders.Add($env:SystemRoot)
|
||||
$searchFolders.Add((Get-Location).Path)
|
||||
foreach ($part in ($env:PATH -split ';')) { if ($part) { $searchFolders.Add($part.Trim('"')) } }
|
||||
# Cargo adds native build output directories while running a harness. Record all
|
||||
# candidates, but do not claim this broad diagnostic order is Cargo's exact order.
|
||||
Get-ChildItem 'apps/desktop/src-tauri/target/release/build/*/out' -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { $searchFolders.Add($_.FullName) }
|
||||
$knownDlls = @{}
|
||||
$known = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs'
|
||||
foreach ($property in $known.PSObject.Properties) {
|
||||
if ($property.Value -is [string] -and $property.Value -like '*.dll') { $knownDlls[$property.Value.ToLowerInvariant()] = $true }
|
||||
}
|
||||
$modules = @{}
|
||||
$findings = [Collections.Generic.List[object]]::new()
|
||||
function Get-ProbeModule([string]$Dll) {
|
||||
$key = $Dll.ToLowerInvariant()
|
||||
if ($modules.ContainsKey($key)) { return $modules[$key] }
|
||||
$candidates = @()
|
||||
foreach ($directory in $searchFolders) {
|
||||
$file = Join-Path $directory $Dll
|
||||
if (Test-Path -LiteralPath $file -PathType Leaf) { $candidates += $file }
|
||||
}
|
||||
$requested = $Dll
|
||||
if (-not ($key.StartsWith('api-ms-') -or $key.StartsWith('ext-ms-') -or $knownDlls.ContainsKey($key))) {
|
||||
if ($candidates.Count) { $requested = $candidates[0] }
|
||||
}
|
||||
$handle = [CodexxLoaderProbe]::LoadLibraryExW($requested,[IntPtr]::Zero,1)
|
||||
$errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
||||
$resolved = $null
|
||||
if ($handle -ne [IntPtr]::Zero) {
|
||||
$buffer = [Text.StringBuilder]::new(4096)
|
||||
if ([CodexxLoaderProbe]::GetModuleFileNameW($handle,$buffer,$buffer.Capacity)) { $resolved = $buffer.ToString() }
|
||||
}
|
||||
$record = [ordered]@{ Requested=$Dll; LoadArgument=$requested; Path=$resolved; Handle=$handle; LoadError=$errorCode; Candidates=$candidates }
|
||||
if ($resolved) {
|
||||
$record['Version'] = (Get-Item -LiteralPath $resolved).VersionInfo.FileVersion
|
||||
$record['Sha256'] = (Get-FileHash -LiteralPath $resolved -Algorithm SHA256).Hash
|
||||
& $Dumpbin /exports $resolved 2>&1 | Out-File (Join-Path $OutputDirectory ('exports-' + $Dll + '.txt')) -Encoding utf8
|
||||
}
|
||||
$modules[$key] = $record
|
||||
return $record
|
||||
}
|
||||
function Test-ProbeExport([string]$Dll,[string]$Name,[ushort]$Ordinal,[bool]$ByOrdinal,[string[]]$Chain=@()) {
|
||||
$symbol = if ($ByOrdinal) { "#$Ordinal" } else { $Name }
|
||||
$node = "$Dll!$symbol"
|
||||
if ($Chain.Count -ge 20 -or $node -in $Chain) {
|
||||
$findings.Add([ordered]@{ Kind='ForwarderCycle'; Import=$node; Chain=$Chain }); return
|
||||
}
|
||||
$Chain += $node
|
||||
$module = Get-ProbeModule $Dll
|
||||
if (-not $module.Path) {
|
||||
$findings.Add([ordered]@{ Kind='ModuleLoadFailure'; Import=$node; Win32=$module.LoadError; LoadArgument=$module.LoadArgument; Chain=$Chain }); return
|
||||
}
|
||||
try { $export = [CodexxLoaderProbe]::Export($module.Path,$Name,$Ordinal,$ByOrdinal) }
|
||||
catch {
|
||||
$findings.Add([ordered]@{ Kind='ExportParseFailure'; Import=$node; Path=$module.Path; Detail=$_.Exception.GetType().FullName; Chain=$Chain }); return
|
||||
}
|
||||
if (-not $export.Exists) {
|
||||
$findings.Add([ordered]@{ Kind='MissingExport'; Import=$node; Path=$module.Path; Chain=$Chain }); return
|
||||
}
|
||||
if ($export.Forwarder) {
|
||||
$split = $export.Forwarder.LastIndexOf('.')
|
||||
if ($split -lt 1) { $findings.Add([ordered]@{ Kind='MalformedForwarder'; Import=$node; Path=$module.Path; Forwarder=$export.Forwarder }); return }
|
||||
$targetDll = $export.Forwarder.Substring(0,$split)
|
||||
if (-not $targetDll.EndsWith('.dll',[StringComparison]::OrdinalIgnoreCase)) { $targetDll += '.dll' }
|
||||
$target = $export.Forwarder.Substring($split+1)
|
||||
if ($target.StartsWith('#')) { Test-ProbeExport $targetDll $null ([ushort]$target.Substring(1)) $true $Chain }
|
||||
else { Test-ProbeExport $targetDll $target 0 $false $Chain }
|
||||
return
|
||||
}
|
||||
$address = if ($ByOrdinal) { [CodexxLoaderProbe]::GetProcAddressOrdinal($module.Handle,[IntPtr]::new($Ordinal)) }
|
||||
else { [CodexxLoaderProbe]::GetProcAddress($module.Handle,$Name) }
|
||||
if ($address -eq [IntPtr]::Zero) {
|
||||
$findings.Add([ordered]@{ Kind='GetProcAddressFailure'; Import=$node; Path=$module.Path; Win32=[Runtime.InteropServices.Marshal]::GetLastWin32Error(); Chain=$Chain })
|
||||
}
|
||||
}
|
||||
try {
|
||||
$imports = [CodexxLoaderProbe]::Imports($TestExe)
|
||||
foreach ($import in $imports) { Test-ProbeExport $import.Dll $import.Name $import.Ordinal $import.ByOrdinal }
|
||||
# A STATUS_ENTRYPOINT_NOT_FOUND can be in a dependency's imports even when
|
||||
# the executable's direct imports exist. Traverse the resolved DLL closure.
|
||||
$visited = @{}
|
||||
$nestedCount = 0
|
||||
do {
|
||||
$pending = @($modules.Values | Where-Object { $_.Path -and -not $visited.ContainsKey($_.Path.ToLowerInvariant()) })
|
||||
foreach ($module in $pending) {
|
||||
$visited[$module.Path.ToLowerInvariant()] = $true
|
||||
if ($visited.Count -gt 512) { throw 'Dependency traversal exceeded its diagnostic bound.' }
|
||||
try { $nested = [CodexxLoaderProbe]::Imports($module.Path) }
|
||||
catch { $findings.Add([ordered]@{ Kind='ImportParseFailure'; Path=$module.Path; Detail=$_.Exception.GetType().FullName }); continue }
|
||||
$nestedCount += $nested.Count
|
||||
foreach ($import in $nested) { Test-ProbeExport $import.Dll $import.Name $import.Ordinal $import.ByOrdinal }
|
||||
}
|
||||
} while ($pending.Count -gt 0)
|
||||
$report['ImportCount'] = $imports.Count
|
||||
$report['NestedImportCount'] = $nestedCount
|
||||
$report['Findings'] = @($findings.ToArray())
|
||||
$report['Modules'] = @($modules.Values | ForEach-Object {
|
||||
[ordered]@{ Requested=$_.Requested; LoadArgument=$_.LoadArgument; Path=$_.Path; Version=$_.Version; Sha256=$_.Sha256; LoadError=$_.LoadError; Candidates=$_.Candidates }
|
||||
})
|
||||
if ($RunHarnessList) {
|
||||
$previousMode = [CodexxLoaderProbe]::SetErrorMode(0x8003)
|
||||
try {
|
||||
$start = @{
|
||||
FilePath=$TestExe; ArgumentList='--list'; PassThru=$true; NoNewWindow=$true; Wait=$true
|
||||
RedirectStandardOutput=(Join-Path $OutputDirectory 'harness-list.stdout.txt')
|
||||
RedirectStandardError=(Join-Path $OutputDirectory 'harness-list.stderr.txt')
|
||||
}
|
||||
$process = Start-Process @start
|
||||
$bits = [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$process.ExitCode),0)
|
||||
$report['HarnessListExit'] = ('0x{0:X8}' -f $bits)
|
||||
} finally { [void][CodexxLoaderProbe]::SetErrorMode($previousMode) }
|
||||
}
|
||||
Copy-Item -LiteralPath $TestExe -Destination (Join-Path $OutputDirectory (Split-Path -Leaf $TestExe))
|
||||
$report | ConvertTo-Json -Depth 12 | Out-File (Join-Path $OutputDirectory 'diagnostics.json') -Encoding utf8
|
||||
Write-Host "DLL diagnostics: $OutputDirectory"
|
||||
Write-Host "Inspected $($imports.Count) imports; findings: $($findings.Count)"
|
||||
foreach ($finding in $findings) { Write-Host ($finding | ConvertTo-Json -Compress -Depth 8) }
|
||||
} finally {
|
||||
foreach ($module in $modules.Values) { if ($module.Handle -ne [IntPtr]::Zero) { [void][CodexxLoaderProbe]::FreeLibrary($module.Handle) } }
|
||||
}
|
||||
Reference in New Issue
Block a user