* fix(gitignore): add .codex to ignored files

* feat(settings): add configurable default terminal for CLI agents

- Add default terminal selector under Advanced Settings > Software Settings
- Detect available terminals across macOS, Windows, and Linux
- Persist and migrate the default-terminal configuration
- Apply the selected terminal when launching CLI agents
- Add localized labels and regression tests

Fixes #179

* feat(easy-mode): improve API onboarding and agent configuration flow

- Fix management API 404 errors when saving API providers
- Simplify the model list and support custom model IDs
- Adjust API field order and default provider selection
- Reuse the full agent configuration page and restore agent detection

* refactor(agents): simplify embedded beginner mode UI

* style(easy-mode): align onboarding heading typography

* fix(easy-mode): refine API onboarding actions and notifications

- Reveal model selection and save actions only after fetching models
- Move the API save success toast to the bottom-right corner
- Auto-dismiss the success notification after a short delay
- Remove icons from the model fetch and save buttons

* feat(easy-mode): refine guided setup flow and spotlight positioning

- Enforce completion of each selection, input, and configuration step
- Align OAuth, API access, and agent setup guidance with actual UI actions
- Prevent guidance cards from overlapping highlighted areas
- Improve bilingual step descriptions and completion states

* feat(easy-mode): refine onboarding flow and navigation controls

- Enforce accurate step validation for OAuth and API setup
- Select all fetched API models by default
- Prevent guide cards from overlapping highlighted areas
- Improve onboarding copy and remove unnecessary dashboard actions
- Reposition and highlight guide and exit controls

* fix(usage): filter control messages and repair unknown records

- filter empty, null, refresh, and support_refresh messages before persistence
- require request_id for usage records
- repair Claude cache token accounting
- delete historical unknown records
- show deleted record count in usage management

* fix(ui): improve auth file and core install notifications

- show auth file operation notices as auto-dismiss toasts
- prevent duplicate core install completion notifications
- close the install dialog automatically after successful installation
- bump core version to 7.2.146

* fix(ui): improve responsive layouts and core install notifications

- add workspace container breakpoints for management and usage pages
- make the usage table fill the available viewport height
- reduce unnecessary blank space in usage records
- silence automatic bundled core install completion notifications
- deduplicate manual core install completion feedback

* chore(version): update core version to 7.2.147

* fix(management): bypass system proxy for local Core requests

* fix(claude-desktop): support Claude OAuth model aliases

- Resolve Claude Desktop model sources from all OAuth definitions
- Write managed aliases to the matching OAuth channel
- Preserve alias metadata and add regression coverage

* test(claude-desktop): cover xAI OAuth model aliases

* feat(config): add disable cooling toggle to retry settings

* feat(config): add disable cooling toggle and refine settings layout

* fix(dependencies): update cpa-gui version to 0.2.69
This commit is contained in:
lzt404 authored and GitHub committed 2026-09-02 16:59:09 +08:00
1 parent ac9f117c2e
commit 708a834e24
18 files changed
+313 -51

No files matched your search

+1 -1
View File
@@ -1 +1 @@
7.2.146
7.2.147
+1 -1
View File
@@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.66"
version = "0.2.69"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "cpa-gui"
# Single source of truth for the desktop app version.
version = "0.2.66"
version = "0.2.69"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
+55 -28
View File
@@ -140,7 +140,7 @@ pub(crate) async fn fetch_active_oauth_alias_channels(
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| format!("读取 OAuth 凭据来源失败: {error}"))?;
.map_err(|error| format_management_request_error("读取 OAuth 凭据来源失败", &error))?;
let payload = read_management_value(response).await?;
let files = payload
.get("files")
@@ -218,7 +218,7 @@ pub(crate) async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Resu
)
.send()
.await
.map_err(|error| format!("读取内核 YAML 配置失败: {error}"))?;
.map_err(|error| format_management_request_error("读取内核 YAML 配置失败", &error))?;
read_management_text(response).await
}
@@ -234,7 +234,7 @@ pub(crate) async fn put_management_config_yaml(
.body(content.to_string())
.send()
.await
.map_err(|error| format!("保存内核 YAML 配置失败: {error}"))?;
.map_err(|error| format_management_request_error("保存内核 YAML 配置失败", &error))?;
read_management_value(response).await.map(|_| ())
}
@@ -249,7 +249,7 @@ pub(crate) async fn put_management_oauth_model_aliases(
.json(aliases)
.send()
.await
.map_err(|error| format!("保存 OAuth 模型别名失败: {error}"))?;
.map_err(|error| format_management_request_error("保存 OAuth 模型别名失败", &error))?;
read_management_value(response).await.map(|_| ())
}
@@ -319,11 +319,9 @@ pub(crate) async fn ensure_claude_desktop_model_aliases(
let content = fetch_management_config_yaml(config).await?;
let updated = match ensure_claude_desktop_model_aliases_in_yaml(&content, mappings, models) {
Ok(updated) => updated,
Err(source_error) => {
let definitions = fetch_codex_model_definitions(config)
.await
.map_err(|error| format!("{source_error};{error}"))?;
ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml(
Err(_) => {
let definitions = fetch_oauth_model_definitions(config).await;
ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
&content,
mappings,
models,
@@ -347,6 +345,26 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml(
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
codex_oauth_models: &[CodexModelDefinition],
) -> Result<String, String> {
let codex_channel = oauth_alias_channel("codex")
.ok_or_else(|| "缺少 Codex OAuth 别名 channel 定义".to_string())?;
let definitions = [OAuthModelDefinitions {
channel: codex_channel,
models: codex_oauth_models.to_vec(),
}];
ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
content,
mappings,
models,
&definitions,
)
}
pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
content: &str,
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
oauth_model_definitions: &[OAuthModelDefinitions],
) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
@@ -368,7 +386,7 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml(
remove_managed_claude_model_alias(root, alias)?;
}
} else {
ensure_claude_desktop_model_alias(root, source_model, alias, codex_oauth_models)?;
ensure_claude_desktop_model_alias(root, source_model, alias, oauth_model_definitions)?;
}
}
render_updated_core_yaml(&mut document, updated)
@@ -559,7 +577,7 @@ pub(crate) fn ensure_claude_desktop_model_alias(
root: &mut serde_norway::Mapping,
source_model: &str,
alias: &str,
codex_oauth_models: &[CodexModelDefinition],
oauth_model_definitions: &[OAuthModelDefinitions],
) -> Result<(), String> {
if let Some((existing_source, _)) = configured_model_client_identity(root, alias) {
if existing_source.eq_ignore_ascii_case(source_model)
@@ -572,11 +590,19 @@ pub(crate) fn ensure_claude_desktop_model_alias(
if append_claude_desktop_model_alias(root, source_model, alias)? {
return Ok(());
}
if codex_oauth_models
.iter()
.any(|model| model.id.eq_ignore_ascii_case(source_model))
{
append_managed_codex_oauth_model_alias(root, source_model, alias)?;
if let Some(definition) = oauth_model_definitions.iter().find(|definition| {
definition
.models
.iter()
.any(|model| model.id.eq_ignore_ascii_case(source_model))
}) {
append_managed_oauth_model_alias(
root,
definition.channel.key,
source_model,
alias,
definition.channel.force_mapping,
)?;
return Ok(());
}
Err(format!(
@@ -695,21 +721,23 @@ pub(crate) fn append_claude_desktop_model_alias(
Ok(false)
}
pub(crate) fn append_managed_codex_oauth_model_alias(
pub(crate) fn append_managed_oauth_model_alias(
root: &mut serde_norway::Mapping,
channel: &str,
source_model: &str,
alias: &str,
force_mapping: bool,
) -> Result<(), String> {
let oauth_aliases = root
.entry(yaml_key("oauth-model-alias"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
let codex_aliases = oauth_aliases
.entry(yaml_key("codex"))
let channel_aliases = oauth_aliases
.entry(yaml_key(channel))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?;
.ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?;
let display_name = managed_claude_alias_display_name(alias)
.ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?;
let mut alias_mapping = serde_norway::Mapping::new();
@@ -722,20 +750,17 @@ pub(crate) fn append_managed_codex_oauth_model_alias(
serde_norway::Value::String(alias.to_string()),
);
alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true));
if force_mapping {
alias_mapping.insert(yaml_key("force-mapping"), serde_norway::Value::Bool(true));
}
alias_mapping.insert(
yaml_key("display-name"),
serde_norway::Value::String(display_name.to_string()),
);
codex_aliases.push(serde_norway::Value::Mapping(alias_mapping));
channel_aliases.push(serde_norway::Value::Mapping(alias_mapping));
Ok(())
}
pub(crate) async fn fetch_codex_model_definitions(
config: &GuiConfigFile,
) -> Result<Vec<CodexModelDefinition>, String> {
fetch_oauth_channel_model_definitions(config, "codex").await
}
pub(crate) async fn fetch_oauth_channel_model_definitions(
config: &GuiConfigFile,
channel: &str,
@@ -750,7 +775,9 @@ pub(crate) async fn fetch_oauth_channel_model_definitions(
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| format!("读取 {channel} OAuth 模型定义失败: {error}"))?;
.map_err(|error| {
format_management_request_error(&format!("读取 {channel} OAuth 模型定义失败"), &error)
})?;
let payload = read_management_value(response).await?;
parse_codex_model_definitions(&payload)
}
+2
View File
@@ -119,6 +119,7 @@ pub(crate) fn save_network_routing_settings(
next.proxy_url = proxy_url.clone();
next.routing_session_affinity = settings.routing_session_affinity;
next.routing_session_affinity_ttl = routing_session_affinity_ttl.clone();
next.disable_cooling = settings.disable_cooling;
next.request_retry = settings.request_retry;
next.max_retry_credentials = settings.max_retry_credentials;
next.max_retry_interval = settings.max_retry_interval;
@@ -187,6 +188,7 @@ pub(crate) fn save_retry_settings(
) -> Result<CoreConfigView, String> {
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
next.disable_cooling = settings.disable_cooling;
next.request_retry = settings.request_retry;
next.max_retry_credentials = settings.max_retry_credentials;
next.max_retry_interval = settings.max_retry_interval;
+21
View File
@@ -415,6 +415,11 @@ pub(crate) fn patch_core_network_routing_yaml(
"session-affinity-ttl",
serde_norway::Value::String(config.routing_session_affinity_ttl.clone()),
)?;
set_core_yaml_top_level_value(
document,
"disable-cooling",
serde_norway::Value::Bool(config.disable_cooling),
)?;
set_core_yaml_top_level_value(
document,
"request-retry",
@@ -466,6 +471,11 @@ pub(crate) fn patch_core_retry_yaml(
) -> Result<Option<String>, String> {
patch_core_yaml_document(content, |document| {
let original = document.clone();
set_core_yaml_top_level_value(
document,
"disable-cooling",
serde_norway::Value::Bool(config.disable_cooling),
)?;
set_core_yaml_top_level_value(
document,
"request-retry",
@@ -615,6 +625,11 @@ pub(crate) fn apply_gui_managed_settings(
"session-affinity-ttl",
serde_norway::Value::String(config.routing_session_affinity_ttl.clone()),
)?;
changed |= set_core_yaml_top_level_value(
document,
"disable-cooling",
serde_norway::Value::Bool(config.disable_cooling),
)?;
changed |= set_core_yaml_top_level_value(
document,
"request-retry",
@@ -1146,6 +1161,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|| presence.proxy_url.is_none()
|| presence.routing_session_affinity.is_none()
|| presence.routing_session_affinity_ttl.is_none()
|| presence.disable_cooling.is_none()
|| presence.request_retry.is_none()
|| presence.max_retry_credentials.is_none()
|| presence.max_retry_interval.is_none()
@@ -1197,6 +1213,9 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
if presence.routing_session_affinity_ttl.is_none() {
config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl;
}
if presence.disable_cooling.is_none() {
config.disable_cooling = core_settings.disable_cooling;
}
if presence.request_retry.is_none() {
config.request_retry = core_settings.request_retry;
}
@@ -1301,6 +1320,7 @@ pub(crate) fn apply_core_settings_to_gui_config(
config.proxy_url = core_settings.proxy_url.clone();
config.routing_session_affinity = core_settings.routing_session_affinity;
config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl.clone();
config.disable_cooling = core_settings.disable_cooling;
config.request_retry = core_settings.request_retry;
config.max_retry_credentials = core_settings.max_retry_credentials;
config.max_retry_interval = core_settings.max_retry_interval;
@@ -1676,6 +1696,7 @@ pub(crate) fn write_gui_config_to_path(
"routing-session-affinity-ttl",
value(config.routing_session_affinity_ttl.as_str()),
),
("disable-cooling", value(config.disable_cooling)),
("request-retry", value(i64::from(config.request_retry))),
(
"max-retry-credentials",
+9
View File
@@ -1197,6 +1197,14 @@ pub(crate) fn core_config_settings_from_value(
})
.transpose()?
.unwrap_or_default();
let disable_cooling = yaml_mapping_value(root, "disable-cooling")
.map(|value| {
value
.as_bool()
.ok_or_else(|| "disable-cooling 必须是布尔值".to_string())
})
.transpose()?
.unwrap_or(DEFAULT_DISABLE_COOLING);
let request_retry = yaml_mapping_value(root, "request-retry")
.map(|value| {
value
@@ -1248,6 +1256,7 @@ pub(crate) fn core_config_settings_from_value(
proxy_url,
routing_session_affinity,
routing_session_affinity_ttl,
disable_cooling,
request_retry,
max_retry_credentials,
max_retry_interval,
+17 -2
View File
@@ -20,8 +20,8 @@ mod usage;
#[cfg(test)]
use configuration_watcher::nearest_existing_watch_directory;
use management_api::{
management_authorization, management_endpoint, management_http_client, read_management_text,
read_management_value,
format_management_request_error, management_authorization, management_endpoint,
management_http_client, read_management_text, read_management_value,
};
use oauth_browser::*;
#[cfg(test)]
@@ -137,6 +137,7 @@ const DEFAULT_REQUEST_RETRY: u32 = 3;
const DEFAULT_MAX_RETRY_CREDENTIALS: u32 = 0;
const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30;
const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0;
const DEFAULT_DISABLE_COOLING: bool = false;
const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456";
const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui";
const ZCODE_CONFIG_FILE: &str = "config.json";
@@ -605,6 +606,7 @@ struct GuiConfigFile {
prefer_gitcode_downloads: bool,
routing_session_affinity: bool,
routing_session_affinity_ttl: String,
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
@@ -889,6 +891,7 @@ impl Default for GuiConfigFile {
prefer_gitcode_downloads: false,
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
disable_cooling: DEFAULT_DISABLE_COOLING,
request_retry: DEFAULT_REQUEST_RETRY,
max_retry_credentials: DEFAULT_MAX_RETRY_CREDENTIALS,
max_retry_interval: DEFAULT_MAX_RETRY_INTERVAL,
@@ -922,6 +925,7 @@ struct GuiConfigPresence {
prefer_gitcode_downloads: Option<bool>,
routing_session_affinity: Option<bool>,
routing_session_affinity_ttl: Option<String>,
disable_cooling: Option<bool>,
request_retry: Option<u32>,
max_retry_credentials: Option<u32>,
max_retry_interval: Option<u32>,
@@ -1330,6 +1334,8 @@ struct GuiNetworkRoutingSettings {
proxy_url: String,
routing_session_affinity: bool,
routing_session_affinity_ttl: String,
#[serde(default)]
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
@@ -1347,6 +1353,8 @@ struct GuiNetworkEndpointSettings {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GuiRetrySettings {
#[serde(default)]
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
@@ -1386,6 +1394,7 @@ struct CoreConfigSettings {
proxy_url: String,
routing_session_affinity: bool,
routing_session_affinity_ttl: String,
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
@@ -1416,6 +1425,7 @@ struct CoreConfigView {
proxy_url: String,
routing_session_affinity: bool,
routing_session_affinity_ttl: String,
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
@@ -1860,6 +1870,7 @@ impl GuiConfigState {
config.proxy_url = settings.proxy_url.clone();
config.routing_session_affinity = settings.routing_session_affinity;
config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone();
config.disable_cooling = settings.disable_cooling;
config.request_retry = settings.request_retry;
config.max_retry_credentials = settings.max_retry_credentials;
config.max_retry_interval = settings.max_retry_interval;
@@ -1880,6 +1891,7 @@ impl GuiConfigState {
fn update_retry_settings(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.disable_cooling = settings.disable_cooling;
config.request_retry = settings.request_retry;
config.max_retry_credentials = settings.max_retry_credentials;
config.max_retry_interval = settings.max_retry_interval;
@@ -2035,6 +2047,7 @@ impl GuiConfigState {
config.proxy_url = settings.proxy_url.clone();
config.routing_session_affinity = settings.routing_session_affinity;
config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone();
config.disable_cooling = settings.disable_cooling;
config.request_retry = settings.request_retry;
config.max_retry_credentials = settings.max_retry_credentials;
config.max_retry_interval = settings.max_retry_interval;
@@ -2086,6 +2099,7 @@ impl From<&GuiConfigFile> for CoreConfigSettings {
proxy_url: config.proxy_url.clone(),
routing_session_affinity: config.routing_session_affinity,
routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(),
disable_cooling: config.disable_cooling,
request_retry: config.request_retry,
max_retry_credentials: config.max_retry_credentials,
max_retry_interval: config.max_retry_interval,
@@ -2115,6 +2129,7 @@ impl From<&GuiConfigFile> for CoreConfigView {
proxy_url: config.proxy_url.clone(),
routing_session_affinity: config.routing_session_affinity,
routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(),
disable_cooling: config.disable_cooling,
request_retry: config.request_retry,
max_retry_credentials: config.max_retry_credentials,
max_retry_interval: config.max_retry_interval,
+42 -10
View File
@@ -8,6 +8,7 @@ use super::{
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
error::Error,
fs,
path::Path,
process::{Command, Stdio},
@@ -94,7 +95,7 @@ pub(crate) async fn management_request(
let response = builder
.send()
.await
.map_err(|err| format!("请求管理 API 失败: {err}"))?;
.map_err(|err| format_management_request_error("请求管理 API 失败", &err))?;
read_management_value(response).await
}
@@ -121,7 +122,7 @@ pub(crate) async fn upload_auth_file(
.body(data)
.send()
.await
.map_err(|err| format!("上传认证文件失败: {err}"))?;
.map_err(|err| format_management_request_error("上传认证文件失败", &err))?;
read_management_value(response).await
}
@@ -179,7 +180,7 @@ pub(crate) async fn start_oauth_login(
let response = request
.send()
.await
.map_err(|err| format!("请求 OAuth 登录链接失败: {err}"))?;
.map_err(|err| format_management_request_error("请求 OAuth 登录链接失败", &err))?;
let payload = read_management_json::<OAuthStartApiResponse>(response).await?;
if let Some(error) = payload
.error
@@ -228,7 +229,7 @@ pub(crate) async fn get_oauth_status(
.query(&[("state", state)])
.send()
.await
.map_err(|err| format!("查询 OAuth 状态失败: {err}"))?;
.map_err(|err| format_management_request_error("查询 OAuth 状态失败", &err))?;
let payload = read_management_json::<OAuthStatusApiResponse>(response).await?;
let status = payload
.status
@@ -268,12 +269,12 @@ pub(crate) async fn submit_oauth_callback(
.json(&body)
.send()
.await
.map_err(|err| format!("提交 OAuth 回调失败: {err}"))?;
.map_err(|err| format_management_request_error("提交 OAuth 回调失败", &err))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取 OAuth 回调响应失败: {err}"))?;
.map_err(|err| format_management_request_error("读取 OAuth 回调响应失败", &err))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
@@ -282,13 +283,44 @@ pub(crate) async fn submit_oauth_callback(
pub(crate) fn management_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
// GUI-to-Core management traffic must always connect directly. Upstream
// traffic still uses Core's proxy-url, and other GUI HTTP clients keep
// their independently configured proxy behavior.
.no_proxy()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
// Management requests target the configured local listener. This keeps
// self-signed/private-CA certificates usable without weakening upstream clients.
.danger_accept_invalid_certs(true)
.build()
.map_err(|err| format!("创建管理 API 客户端失败: {err}"))
.map_err(|err| format_management_request_error("创建管理 API 客户端失败", &err))
}
pub(crate) fn format_management_request_error(
action: &str,
error: &(dyn Error + 'static),
) -> String {
let mut messages = Vec::new();
let mut current = Some(error);
while let Some(error) = current {
let message = error.to_string();
if !message.is_empty()
&& messages
.last()
.map(|previous| previous != &message)
.unwrap_or(true)
{
messages.push(message);
}
current = error.source();
}
if messages.is_empty() {
action.to_string()
} else {
format!("{action}: {}", messages.join(": "))
}
}
pub(crate) fn management_authorization(config: &GuiConfigFile) -> Result<String, String> {
@@ -342,7 +374,7 @@ where
let text = response
.text()
.await
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
.map_err(|err| format_management_request_error("读取管理 API 响应失败", &err))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
@@ -364,7 +396,7 @@ pub(crate) async fn read_management_value(
let text = response
.text()
.await
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
.map_err(|err| format_management_request_error("读取管理 API 响应失败", &err))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
@@ -382,7 +414,7 @@ pub(crate) async fn read_management_text(response: reqwest::Response) -> Result<
let text = response
.text()
.await
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
.map_err(|err| format_management_request_error("读取管理 API 响应失败", &err))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
@@ -1674,6 +1674,104 @@ fn claude_desktop_aliases_support_codex_oauth_models() {
);
}
#[test]
fn claude_desktop_aliases_support_claude_oauth_models() {
let input = "port: 8317\n";
let mappings = ClaudeDesktopModelMappings::all("claude-sonnet-4-6");
let available_models = test_agent_models(&["claude-sonnet-4-6"]);
let definitions = vec![OAuthModelDefinitions {
channel: oauth_alias_channel("claude").unwrap(),
models: vec![CodexModelDefinition {
id: "claude-sonnet-4-6".to_string(),
display_name: None,
description: None,
context_window: Some(200_000),
reasoning_levels: vec!["high".to_string()],
supports_tools: Some(true),
}],
}];
let rendered = ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
input,
&mappings,
&available_models,
&definitions,
)
.unwrap();
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
let root = value.as_mapping().unwrap();
let aliases = yaml_mapping_value(root, "oauth-model-alias")
.and_then(serde_norway::Value::as_mapping)
.and_then(|channels| yaml_mapping_value(channels, "claude"))
.and_then(serde_norway::Value::as_sequence)
.unwrap();
assert_eq!(aliases.len(), 3);
assert!(aliases.iter().all(|entry| {
configured_model_identity(entry).is_some_and(|(source, _, _)| source == "claude-sonnet-4-6")
}));
assert!(aliases.iter().all(|entry| {
entry
.as_mapping()
.and_then(|entry| yaml_mapping_value(entry, "fork"))
== Some(&serde_norway::Value::Bool(true))
}));
assert_eq!(
ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
&rendered,
&mappings,
&available_models,
&definitions,
)
.unwrap(),
rendered
);
}
#[test]
fn claude_desktop_aliases_support_xai_oauth_models() {
let input = "port: 8317\n";
let mappings = ClaudeDesktopModelMappings::all("grok-4");
let available_models = test_agent_models(&["grok-4"]);
let definitions = vec![OAuthModelDefinitions {
channel: oauth_alias_channel("xai").unwrap(),
models: vec![CodexModelDefinition {
id: "grok-4".to_string(),
display_name: None,
description: None,
context_window: Some(131_072),
reasoning_levels: vec!["high".to_string()],
supports_tools: Some(true),
}],
}];
let rendered = ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
input,
&mappings,
&available_models,
&definitions,
)
.unwrap();
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
let root = value.as_mapping().unwrap();
let aliases = yaml_mapping_value(root, "oauth-model-alias")
.and_then(serde_norway::Value::as_mapping)
.and_then(|channels| yaml_mapping_value(channels, "xai"))
.and_then(serde_norway::Value::as_sequence)
.unwrap();
assert_eq!(aliases.len(), 3);
assert!(aliases.iter().all(|entry| {
configured_model_identity(entry).is_some_and(|(source, _, _)| source == "grok-4")
}));
assert!(aliases.iter().all(|entry| {
entry
.as_mapping()
.and_then(|entry| yaml_mapping_value(entry, "fork"))
== Some(&serde_norway::Value::Bool(true))
}));
}
#[test]
fn claude_oauth_alias_changes_use_the_runtime_refresh_endpoint() {
let current = "port: 8317\nopenai-compatibility: []\n";
+2
View File
@@ -18,6 +18,7 @@ fn gui_field_edit_preserves_comments_and_unknown_configuration() {
auth_dir: path_to_string(&home.join("custom-auth")),
management_secret_key: "custom-secret".to_string(),
usage_statistics_enabled: false,
disable_cooling: true,
download_source: VersionDownloadSource::Gitcode,
prefer_gitcode_downloads: true,
..GuiConfigFile::default()
@@ -33,6 +34,7 @@ fn gui_field_edit_preserves_comments_and_unknown_configuration() {
assert!(content.contains("silent-start = true"));
assert!(content.contains("management-secret-key = \"custom-secret\""));
assert!(content.contains("usage-statistics-enabled = false"));
assert!(content.contains("disable-cooling = true"));
assert!(content.contains("download-source = \"gitcode\""));
assert!(content.contains("prefer-gitcode-downloads = true"));
assert!(!content.contains("codex-session-repair-on-launch"));
+15 -2
View File
@@ -341,6 +341,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() {
proxy_url: "http://127.0.0.1:8080".to_string(),
routing_session_affinity: true,
routing_session_affinity_ttl: "45m".to_string(),
disable_cooling: true,
request_retry: 1,
max_retry_credentials: 2,
max_retry_interval: 5,
@@ -358,6 +359,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() {
assert_eq!(config.management_secret_key, "management-secret");
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 9000);
assert!(config.disable_cooling);
assert_eq!(config.auth_dir, "/tmp/external-auth");
assert!(!config.usage_statistics_enabled);
assert!(config.plugins_enabled);
@@ -479,6 +481,7 @@ fn confirmed_network_routing_patch_updates_all_fields_together() {
proxy_url: "socks5://127.0.0.1:7890".to_string(),
routing_session_affinity: true,
routing_session_affinity_ttl: "2h".to_string(),
disable_cooling: true,
request_retry: 1,
max_retry_credentials: 2,
max_retry_interval: 5,
@@ -496,6 +499,7 @@ fn confirmed_network_routing_patch_updates_all_fields_together() {
assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890");
assert_eq!(document["routing"]["session-affinity"], true);
assert_eq!(document["routing"]["session-affinity-ttl"], "2h");
assert_eq!(document["disable-cooling"], true);
assert_eq!(document["request-retry"], 1);
assert_eq!(document["max-retry-credentials"], 2);
assert_eq!(document["max-retry-interval"], 5);
@@ -515,13 +519,14 @@ fn independent_network_modules_only_patch_their_own_fields() {
proxy_url: "socks5://127.0.0.1:7890".to_string(),
routing_session_affinity: true,
routing_session_affinity_ttl: "2h".to_string(),
disable_cooling: true,
request_retry: 7,
max_retry_credentials: 8,
max_retry_interval: 9,
streaming_bootstrap_retries: 10,
..GuiConfigFile::default()
};
let input = "host: 127.0.0.1\nport: 8317\nproxy-url: \"\"\nrouting:\n session-affinity: false\n session-affinity-ttl: 1h\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 3\nstreaming:\n bootstrap-retries: 4\n";
let input = "host: 127.0.0.1\nport: 8317\nproxy-url: \"\"\nrouting:\n session-affinity: false\n session-affinity-ttl: 1h\ndisable-cooling: false\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 3\nstreaming:\n bootstrap-retries: 4\n";
let network = patch_core_network_endpoint_yaml(input, &config)
.unwrap()
@@ -530,6 +535,7 @@ fn independent_network_modules_only_patch_their_own_fields() {
assert_eq!(network["host"], "192.168.1.20");
assert_eq!(network["port"], 9527);
assert_eq!(network["proxy-url"], "socks5://127.0.0.1:7890");
assert_eq!(network["disable-cooling"], false);
assert_eq!(network["request-retry"], 1);
assert_eq!(network["routing"]["session-affinity"], false);
@@ -537,6 +543,7 @@ fn independent_network_modules_only_patch_their_own_fields() {
.unwrap()
.expect("retry settings should change");
let retry = serde_norway::from_str::<serde_norway::Value>(&retry).unwrap();
assert_eq!(retry["disable-cooling"], true);
assert_eq!(retry["request-retry"], 7);
assert_eq!(retry["max-retry-credentials"], 8);
assert_eq!(retry["max-retry-interval"], 9);
@@ -826,17 +833,19 @@ fn core_config_reads_proxy_and_session_affinity_fields() {
#[test]
fn core_config_reads_retry_fields_and_uses_core_defaults() {
let document = serde_norway::from_str::<serde_norway::Value>(
"request-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 5\nstreaming:\n bootstrap-retries: 4\n",
"disable-cooling: true\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 5\nstreaming:\n bootstrap-retries: 4\n",
)
.unwrap();
let settings = core_config_settings_from_value(&document).unwrap();
assert!(settings.disable_cooling);
assert_eq!(settings.request_retry, 1);
assert_eq!(settings.max_retry_credentials, 2);
assert_eq!(settings.max_retry_interval, 5);
assert_eq!(settings.streaming_bootstrap_retries, 4);
let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap();
assert_eq!(defaults.disable_cooling, DEFAULT_DISABLE_COOLING);
assert_eq!(defaults.request_retry, DEFAULT_REQUEST_RETRY);
assert_eq!(
defaults.max_retry_credentials,
@@ -856,6 +865,7 @@ fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content()
proxy_url: "http://127.0.0.1:8080".to_string(),
routing_session_affinity: true,
routing_session_affinity_ttl: "1h".to_string(),
disable_cooling: true,
request_retry: 1,
max_retry_credentials: 2,
max_retry_interval: 5,
@@ -868,6 +878,7 @@ fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content()
assert_eq!(document["proxy-url"], "http://127.0.0.1:8080");
assert_eq!(document["routing"]["session-affinity"], true);
assert_eq!(document["routing"]["session-affinity-ttl"], "1h");
assert_eq!(document["disable-cooling"], true);
assert_eq!(document["request-retry"], 1);
assert_eq!(document["max-retry-credentials"], 2);
assert_eq!(document["max-retry-interval"], 5);
@@ -1046,6 +1057,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
prefer_gitcode_downloads: false,
routing_session_affinity: true,
routing_session_affinity_ttl: "1h".to_string(),
disable_cooling: true,
request_retry: 1,
max_retry_credentials: 2,
max_retry_interval: 5,
@@ -1072,6 +1084,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890");
assert_eq!(document["routing"]["session-affinity"], true);
assert_eq!(document["routing"]["session-affinity-ttl"], "1h");
assert_eq!(document["disable-cooling"], true);
assert_eq!(document["request-retry"], 1);
assert_eq!(document["max-retry-credentials"], 2);
assert_eq!(document["max-retry-interval"], 5);
+6 -4
View File
@@ -5,8 +5,9 @@ use super::executable_dir;
#[cfg(test)]
use super::VersionDownloadSource;
use super::{
apply_configured_proxy, core_base_dir, current_core_status, management_authorization,
management_endpoint, management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState,
apply_configured_proxy, core_base_dir, current_core_status, format_management_request_error,
management_authorization, management_endpoint, management_http_client, CoreProcessState,
GuiConfigFile, GuiConfigState,
};
use chrono::{DateTime, Local};
use rusqlite::{
@@ -1650,12 +1651,12 @@ async fn fetch_usage_queue(config: &GuiConfigFile) -> Result<Vec<Value>, String>
.query(&[("count", USAGE_QUEUE_BATCH_SIZE)])
.send()
.await
.map_err(|error| format!("读取 CPA 使用记录队列失败: {error}"))?;
.map_err(|error| format_management_request_error("读取 CPA 使用记录队列失败", &error))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|error| format!("读取 CPA 使用记录响应失败: {error}"))?;
.map_err(|error| format_management_request_error("读取 CPA 使用记录响应失败", &error))?;
if !status.is_success() {
return Err(format!(
"CPA 使用记录队列返回 HTTP {}: {}",
@@ -3864,6 +3865,7 @@ mod tests {
prefer_gitcode_downloads: false,
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
disable_cooling: crate::DEFAULT_DISABLE_COOLING,
request_retry: crate::DEFAULT_REQUEST_RETRY,
max_retry_credentials: crate::DEFAULT_MAX_RETRY_CREDENTIALS,
max_retry_interval: crate::DEFAULT_MAX_RETRY_INTERVAL,
+2
View File
@@ -406,6 +406,8 @@ export const jaOverrides = {
'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します',
'config.network.sessionTtl': 'セッションアフィニティ TTL',
'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します',
'config.network.disableCooling': 'クールダウンを無効化',
'config.network.disableCoolingHint': '認証情報とモデルのクールダウン制御を全体で無効にします',
'config.network.requestRetry': 'リクエスト再試行回数',
'config.network.requestRetryHint': '403、408、500、502、503、504 応答の再試行回数です。0 で無効になります',
'config.network.maxRetryCredentials': '試行する認証情報の上限',
+2
View File
@@ -478,6 +478,8 @@ export const en: Record<MessageKey, string> = {
'config.network.sessionAffinityHint': 'Prefer the previously selected credential for the same session',
'config.network.sessionTtl': 'Session Affinity TTL',
'config.network.sessionTtlHint': 'For example, 1h; leave blank to use the core default',
'config.network.disableCooling': 'Disable Cooling',
'config.network.disableCoolingHint': 'Globally disables cooldown scheduling for credentials and models',
'config.network.requestRetry': 'Request Retries',
'config.network.requestRetryHint': 'Retries responses with status 403, 408, 500, 502, 503, or 504; 0 disables retries',
'config.network.maxRetryCredentials': 'Maximum Credentials Tried',
+2
View File
@@ -476,6 +476,8 @@ export const zhCN = {
'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据',
'config.network.sessionTtl': '会话粘性 TTL',
'config.network.sessionTtlHint': '例如 1h;留空使用内核默认值',
'config.network.disableCooling': '禁用冷却',
'config.network.disableCoolingHint': '全局禁用认证凭据和模型的冷却调度',
'config.network.requestRetry': '请求重试次数',
'config.network.requestRetryHint': '对 403、408、500、502、503、504 响应的重试次数;0 表示不重试',
'config.network.maxRetryCredentials': '最大尝试凭据数',
+32 -2
View File
@@ -41,6 +41,7 @@ type CoreConfigSettings = {
proxyUrl: string;
routingSessionAffinity: boolean;
routingSessionAffinityTtl: string;
disableCooling: boolean;
requestRetry: number;
maxRetryCredentials: number;
maxRetryInterval: number;
@@ -72,6 +73,7 @@ type NetworkDraftField =
| 'proxyUrl'
| 'sessionAffinity'
| 'sessionTtl'
| 'disableCooling'
| 'requestRetry'
| 'maxRetryCredentials'
| 'maxRetryInterval'
@@ -106,6 +108,7 @@ const cleanNetworkDraft = (): NetworkDraftDirty => ({
proxyUrl: false,
sessionAffinity: false,
sessionTtl: false,
disableCooling: false,
requestRetry: false,
maxRetryCredentials: false,
maxRetryInterval: false,
@@ -159,6 +162,7 @@ export function ConfigPanelPage() {
const [proxyUrlDraft, setProxyUrlDraft] = useState('');
const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false);
const [sessionTtlDraft, setSessionTtlDraft] = useState('');
const [disableCoolingDraft, setDisableCoolingDraft] = useState(false);
const [requestRetryDraft, setRequestRetryDraft] = useState('3');
const [maxRetryCredentialsDraft, setMaxRetryCredentialsDraft] = useState('0');
const [maxRetryIntervalDraft, setMaxRetryIntervalDraft] = useState('30');
@@ -218,6 +222,7 @@ export function ConfigPanelPage() {
if (!dirty.proxyUrl) setProxyUrlDraft(result.proxyUrl);
if (!dirty.sessionAffinity) setSessionAffinityDraft(result.routingSessionAffinity);
if (!dirty.sessionTtl) setSessionTtlDraft(result.routingSessionAffinityTtl);
if (!dirty.disableCooling) setDisableCoolingDraft(result.disableCooling);
if (!dirty.requestRetry) setRequestRetryDraft(String(result.requestRetry));
if (!dirty.maxRetryCredentials) setMaxRetryCredentialsDraft(String(result.maxRetryCredentials));
if (!dirty.maxRetryInterval) setMaxRetryIntervalDraft(String(result.maxRetryInterval));
@@ -232,6 +237,7 @@ export function ConfigPanelPage() {
setProxyUrlDraft(result.proxyUrl);
setSessionAffinityDraft(result.routingSessionAffinity);
setSessionTtlDraft(result.routingSessionAffinityTtl);
setDisableCoolingDraft(result.disableCooling);
setRequestRetryDraft(String(result.requestRetry));
setMaxRetryCredentialsDraft(String(result.maxRetryCredentials));
setMaxRetryIntervalDraft(String(result.maxRetryInterval));
@@ -648,12 +654,14 @@ export function ConfigPanelPage() {
try {
const result = await invoke<CoreConfigSettings>('save_retry_settings', {
settings: {
disableCooling: disableCoolingDraft,
requestRetry,
maxRetryCredentials,
maxRetryInterval,
streamingBootstrapRetries,
},
});
clearDraftDirty('disableCooling');
clearDraftDirty('requestRetry');
clearDraftDirty('maxRetryCredentials');
clearDraftDirty('maxRetryInterval');
@@ -747,7 +755,8 @@ export function ConfigPanelPage() {
|| sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl
);
const retrySettingsDirty = Boolean(settings) && (
requestRetryDraft !== String(settings?.requestRetry)
disableCoolingDraft !== settings?.disableCooling
|| requestRetryDraft !== String(settings?.requestRetry)
|| maxRetryCredentialsDraft !== String(settings?.maxRetryCredentials)
|| maxRetryIntervalDraft !== String(settings?.maxRetryInterval)
|| streamingBootstrapRetriesDraft !== String(settings?.streamingBootstrapRetries)
@@ -871,7 +880,7 @@ export function ConfigPanelPage() {
{activeSubpage === 'general' ? (
<div
className="config-subpage-panel"
className="config-subpage-panel config-general-subpage"
id="config-subpage-panel-general"
role="tabpanel"
aria-labelledby="config-subpage-tab-general"
@@ -1330,6 +1339,27 @@ export function ConfigPanelPage() {
</button>
</div>
<div className="config-network-grid">
<div className="config-network-field config-network-toggle">
<div>
<span>{t('config.network.disableCooling')}</span>
<small>{t('config.network.disableCoolingHint')}</small>
</div>
<label className="switch-control" title={t('config.network.disableCooling')}>
<input
type="checkbox"
role="switch"
aria-label={t('config.network.disableCooling')}
checked={disableCoolingDraft}
disabled={controlsDisabled}
onChange={(event) => {
markDraftDirty('disableCooling');
setDisableCoolingDraft(event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</label>
</div>
<label className="config-network-field">
<span>{t('config.network.requestRetry')}</span>
<input
+5
View File
@@ -1914,6 +1914,11 @@ textarea:focus-visible {
transform: translateX(20px);
}
.config-general-subpage {
display: grid;
gap: 18px;
}
.config-keys-panel {
display: grid;
min-height: 0;