* chore(release): stop publishing legacy Windows update packages

* feat(agents): support OpenCode Desktop across platforms

Detect CLI and desktop installations on Windows, macOS, and Linux, and add separate launch and restart controls.

Closes #156

* fix(codex): align model display names and show total session count

- preserve runtime model identity in generated catalogs
- count distinct sessions across database and rollout sources
- update session count labels in all supported languages

* fix(codex): keep config and auth files in sync

- generate auth.json for API key configuration
- preserve and validate OAuth credentials
- include auth.json in transactional updates and rollback
- retain compatibility with legacy Codex configuration states

* fix(cpa-gui): update version to 0.2.59 in Cargo files

* fix(codex): preserve OAuth mode after closing configuration

* feat(config): add TLS management and protocol-aware local URLs

- add TLS controls to advanced settings
- persist certificate and private key paths
- restart the core after TLS changes
- switch WebUI, API and management URLs between HTTP and HTTPS
- synchronize agent configurations and detection with the active protocol

* refactor(config): reorganize network, routing, and TLS settings

- split network settings and session routing into separate tabs
- move TLS configuration into network settings
- preserve TLS restart and HTTPS URL synchronization behavior

* feat(config): add independent network settings and custom listen IP

* fix(navigation): allow access to usage records while core is stopped

* refactor(home): show a single API URL based on the configured listen IP

* feat(config): add file pickers for TLS certificate and private key

* fix(usage): distinguish client cancellations from request failures

- persist usage failure status and response details
- classify HTTP 499 and canceled contexts separately
- exclude canceled requests from failure statistics
- add canceled filtering and failure details to the usage UI
- migrate and backfill existing usage records

* feat(ui): revamp usage records and improve app interactions

- redesign usage records dashboard, filters, charts, and pricing views
- add resizable centered columns with persisted widths
- expose OpenCode desktop launch and restart actions
- suppress the API access enabled success notice

* feat(agents): persist applied configurations across app restarts

- preserve applied agent configs and managed Claude aliases on exit
- keep explicit close actions restoring original configurations
- add Codex restart persistence regression coverage
- bump desktop version to 0.2.61
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-23 23:20:40 +08:00
1 parent 2b9ceb11fb
commit 3d44eba492
15 files changed
+2342 -526

No files matched your search

+3 -1
View File
@@ -593,7 +593,9 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.59"
version = "0.2.61"
dependencies = [
"base64 0.22.1",
"chrono",
+3 -1
View File
@@ -1,7 +1,9 @@
[package]
name = "cpa-gui"
# Single source of truth for the desktop app version.
version = "0.2.59"
version = "0.2.61"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
-21
View File
@@ -1396,27 +1396,6 @@ pub(crate) fn restore_agent_applied_state_configuration(
Ok(())
}
pub(crate) fn agent_clients_restored_on_exit() -> [AgentClient; 8] {
[
AgentClient::Codex,
AgentClient::OpenCode,
AgentClient::OpenClaw,
AgentClient::Hermes,
AgentClient::DeepSeekHarness,
AgentClient::ZCode,
AgentClient::KimiCode,
AgentClient::GrokBuild,
]
}
pub(crate) fn restore_all_agent_session_configurations(home: &Path) {
for client in agent_clients_restored_on_exit() {
if let Err(error) = restore_agent_session_configuration(client, home) {
eprintln!("关闭时恢复 {} 配置失败: {error}", client.name());
}
}
}
pub(crate) fn commit_agent_configuration(
client: AgentClient,
home: &Path,
-8
View File
@@ -160,14 +160,6 @@ pub(crate) async fn ensure_claude_desktop_model_aliases(
put_management_alias_config_changes(config, &content, &updated).await
}
pub(crate) async fn remove_managed_claude_model_aliases(
config: &GuiConfigFile,
) -> Result<(), String> {
let content = fetch_management_config_yaml(config).await?;
let updated = remove_managed_claude_model_aliases_in_yaml(&content)?;
put_management_alias_config_changes(config, &content, &updated).await
}
pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml(
content: &str,
mappings: &ClaudeDesktopModelMappings,
-16
View File
@@ -2128,22 +2128,6 @@ fn main() {
tauri::RunEvent::Exit => {
usage::stop_usage_collector(app_handle);
let gui_config_state = app_handle.state::<GuiConfigState>();
match gui_config_state.snapshot() {
Ok(config) => {
if let Err(error) =
tauri::async_runtime::block_on(remove_managed_claude_model_aliases(&config))
{
eprintln!("退出时清理 EasyCLIProxyAPI 托管的 Claude 模型别名失败: {error}");
}
}
Err(error) => {
eprintln!("退出时读取 GUI 配置失败,无法清理 Claude 模型别名: {error}");
}
}
if let Ok(home) = app_handle.path().home_dir() {
let _guard = AGENT_CONFIG_FILE_LOCK.lock();
restore_all_agent_session_configurations(&home);
}
let process_state = app_handle.state::<CoreProcessState>();
shutdown_managed_core(process_state.inner(), gui_config_state.inner());
}
@@ -1414,19 +1414,6 @@ oauth-model-alias:
assert!(find("oauth-fast").is_alias);
}
#[test]
fn exit_preserves_claude_client_configurations() {
let restored = agent_clients_restored_on_exit();
assert!(!restored.contains(&AgentClient::ClaudeCode));
assert!(!restored.contains(&AgentClient::ClaudeDesktop));
assert!(restored.contains(&AgentClient::Codex));
assert!(restored.contains(&AgentClient::OpenCode));
assert!(restored.contains(&AgentClient::DeepSeekHarness));
assert!(restored.contains(&AgentClient::ZCode));
assert!(restored.contains(&AgentClient::KimiCode));
assert!(restored.contains(&AgentClient::GrokBuild));
}
#[test]
fn deepseek_harness_settings_preserve_unrelated_sections_and_publish_models() {
let existing = r#"# keep this comment
+40 -1
View File
@@ -167,7 +167,7 @@ fn codex_oauth_requires_auth_json_with_tokens() {
}
#[test]
fn agent_configuration_is_restored_from_the_dated_session_backup_on_exit() {
fn agent_configuration_is_restored_from_the_dated_session_backup_when_closed() {
let home = agent_test_home("session-backup");
let path = home.join(".config/opencode/opencode.json");
let original = b"{\"provider\":\"original\"}";
@@ -1271,6 +1271,45 @@ fn codex_session_restore_does_not_create_a_state_json_file() {
fs::remove_dir_all(home).unwrap();
}
#[test]
fn codex_applied_configuration_remains_detectable_after_process_restart() {
let home = agent_test_home("codex-applied-across-restart");
let config_path = home.join(".codex/config.toml");
let catalog_path = codex_model_catalog_path(&home);
let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, "approval_policy = \"never\"\n").unwrap();
commit_agent_configuration(
AgentClient::Codex,
&home,
"gpt-test",
&[
AgentFileUpdate {
path: config_path.clone(),
after: "model = \"gpt-test\"\n".to_string(),
},
AgentFileUpdate {
path: catalog_path,
after: "{\"models\":[]}".to_string(),
},
],
"applied",
None,
)
.unwrap();
clear_codex_applied_state(&state_path).unwrap();
let inspection = inspect_agent_application(AgentClient::Codex, &home);
assert_eq!(inspection.state, "applied");
assert_eq!(inspection.applied_model.as_deref(), Some("gpt-test"));
assert!(dated_agent_backup_path(&config_path).unwrap().is_file());
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
fs::remove_dir_all(home).unwrap();
}
#[test]
fn codex_session_restore_recovers_from_a_dated_backup_without_state_json() {
let home = agent_test_home("codex-session-backup-recovery");
+377 -32
View File
@@ -33,9 +33,11 @@ const LEGACY_USAGE_EVENTS_DIR: &str = "events";
const LEGACY_USAGE_INBOX_DIR: &str = "inbox";
const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1";
const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3";
const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4";
const USAGE_UPDATED_EVENT: &str = "usage-records-updated";
const USAGE_SCHEMA_VERSION: u8 = 1;
const USAGE_DATABASE_SCHEMA_VERSION: i64 = 3;
const USAGE_DATABASE_SCHEMA_VERSION: i64 = 4;
const MAX_USAGE_FAILURE_BODY_CHARS: usize = 2_000;
const USAGE_QUEUE_BATCH_SIZE: usize = 500;
const USAGE_INBOX_PROCESS_LIMIT: usize = 500;
const USAGE_INBOX_MAX_ATTEMPTS: i64 = 5;
@@ -150,6 +152,12 @@ pub(crate) struct UsageRecord {
#[serde(default)]
failed: bool,
#[serde(default)]
canceled: bool,
#[serde(default)]
failure_status: u16,
#[serde(default)]
failure_body: String,
#[serde(default)]
provider: String,
#[serde(default, skip_serializing)]
api_group_key: String,
@@ -224,6 +232,8 @@ pub(crate) struct UsageQuery {
#[serde(default)]
failed: Option<bool>,
#[serde(default)]
canceled: Option<bool>,
#[serde(default)]
page: Option<usize>,
#[serde(default)]
page_size: Option<usize>,
@@ -235,6 +245,7 @@ pub(crate) struct UsageOverview {
total_requests: u64,
success_count: u64,
failure_count: u64,
canceled_count: u64,
success_rate: f64,
input_tokens: u64,
output_tokens: u64,
@@ -355,6 +366,7 @@ struct UsageTimelinePoint {
requests: u64,
success: u64,
failure: u64,
canceled: u64,
tokens: u64,
}
@@ -736,6 +748,9 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> {
source TEXT NOT NULL DEFAULT '',
auth_index TEXT NOT NULL DEFAULT '',
failed INTEGER NOT NULL DEFAULT 0,
canceled INTEGER NOT NULL DEFAULT 0,
failure_status INTEGER NOT NULL DEFAULT 0,
failure_body TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
alias TEXT NOT NULL DEFAULT '',
@@ -824,11 +839,111 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> {
"#,
)
.map_err(|error| format!("初始化 SQLite 使用记录结构失败: {error}"))?;
ensure_usage_failure_columns(connection)?;
connection
.execute(
"CREATE INDEX IF NOT EXISTS idx_usage_events_canceled_timestamp ON usage_events(canceled, timestamp_ms DESC)",
[],
)
.map_err(|error| format!("创建 SQLite 取消记录索引失败: {error}"))?;
connection
.pragma_update(None, "user_version", USAGE_DATABASE_SCHEMA_VERSION)
.map_err(|error| format!("更新 SQLite 使用记录版本失败: {error}"))
}
fn ensure_usage_failure_columns(connection: &Connection) -> Result<(), String> {
let mut columns = usage_table_columns(connection, "usage_events")?;
for (column, definition) in [
("canceled", "INTEGER NOT NULL DEFAULT 0"),
("failure_status", "INTEGER NOT NULL DEFAULT 0"),
("failure_body", "TEXT NOT NULL DEFAULT ''"),
] {
if columns.contains(column) {
continue;
}
connection
.execute(
&format!("ALTER TABLE usage_events ADD COLUMN {column} {definition}"),
[],
)
.map_err(|error| format!("添加 SQLite 使用记录字段 {column} 失败: {error}"))?;
columns.insert(column.to_string());
}
backfill_usage_failure_details(connection)?;
Ok(())
}
fn backfill_usage_failure_details(connection: &Connection) -> Result<(), String> {
let migrated = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![USAGE_FAILURE_MIGRATION_KEY],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("读取使用记录失败详情迁移状态失败: {error}"))?;
if migrated.is_some() {
return Ok(());
}
if usage_table_exists(connection, "usage_inbox")? {
let rows = {
let mut statement = connection
.prepare(
r#"
SELECT usage_event_key, raw_message
FROM usage_inbox
WHERE status = 'processed' AND usage_event_key != ''
"#,
)
.map_err(|error| format!("准备回填使用记录失败详情失败: {error}"))?;
let rows = statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|error| format!("查询使用记录失败详情失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取使用记录失败详情失败: {error}"))?;
rows
};
for (event_key, raw_message) in rows {
let Some(object) = serde_json::from_str::<Value>(&raw_message)
.ok()
.and_then(|value| value.as_object().cloned())
else {
continue;
};
if !object
.get("failed")
.and_then(Value::as_bool)
.unwrap_or(false)
{
continue;
}
let (failure_status, failure_body) = usage_failure_details(&object);
let canceled = usage_failure_is_canceled(failure_status, &failure_body);
connection
.execute(
r#"
UPDATE usage_events
SET canceled = ?1, failure_status = ?2, failure_body = ?3
WHERE event_key = ?4
"#,
params![canceled, i64::from(failure_status), failure_body, event_key],
)
.map_err(|error| format!("回填使用记录失败详情失败: {error}"))?;
}
}
connection
.execute(
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)",
params![USAGE_FAILURE_MIGRATION_KEY, Local::now().to_rfc3339()],
)
.map_err(|error| format!("记录使用记录失败详情迁移状态失败: {error}"))?;
Ok(())
}
fn open_usage_database() -> Result<Connection, String> {
open_usage_database_at(&usage_root_dir()?)
}
@@ -1542,12 +1657,13 @@ fn insert_usage_records_in_transaction(
api_group_key, model_alias, client_ip, x_forwarded_for, user_agent,
generate, cached_tokens, collector_source,
input_tokens, output_tokens, reasoning_tokens, cache_read_tokens,
cache_creation_tokens, total_tokens, created_at
cache_creation_tokens, total_tokens, canceled, failure_status,
failure_body, created_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20,
?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30,
?31, ?32, ?33, ?34, ?35, ?36, ?37
?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40
)
"#,
)
@@ -1620,6 +1736,9 @@ fn insert_usage_records_in_transaction(
to_sql_i64(record.tokens.cache_read_tokens),
to_sql_i64(record.tokens.cache_creation_tokens),
to_sql_i64(record.tokens.total_tokens),
record.canceled,
i64::from(record.failure_status),
record.failure_body,
created_at,
])
.map_err(|error| format!("写入 SQLite 使用记录失败: {error}"))?,
@@ -1673,15 +1792,18 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result<UsageR
let provider = string_field(object, "provider").unwrap_or_default();
let endpoint = string_field(object, "endpoint").unwrap_or_default();
let executor_type = string_field(object, "executor_type").unwrap_or_default();
let failed = object
.get("failed")
.and_then(Value::as_bool)
.unwrap_or(false);
let (failure_status, failure_body) = usage_failure_details(object);
let canceled = failed && usage_failure_is_canceled(failure_status, &failure_body);
let generate = object
.get("generate")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
!(executor_type == "CodexWebsocketsExecutor"
&& !object
.get("failed")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !failed
&& tokens.input_tokens == 0
&& tokens.output_tokens == 0
&& tokens.reasoning_tokens == 0
@@ -1708,10 +1830,10 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result<UsageR
source: string_field(object, "source").unwrap_or_default(),
source_display: String::new(),
auth_index: string_field(object, "auth_index").unwrap_or_default(),
failed: object
.get("failed")
.and_then(Value::as_bool)
.unwrap_or(false),
failed,
canceled,
failure_status,
failure_body,
provider,
api_group_key,
model: string_field(object, "model").unwrap_or_else(|| "unknown".to_string()),
@@ -1740,6 +1862,46 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result<UsageR
})
}
fn usage_failure_body(value: &Value) -> String {
let body = match value {
Value::String(value) => value.trim().to_string(),
Value::Null => String::new(),
value => serde_json::to_string(value).unwrap_or_default(),
};
if body.chars().count() <= MAX_USAGE_FAILURE_BODY_CHARS {
body
} else {
let mut bounded = body
.chars()
.take(MAX_USAGE_FAILURE_BODY_CHARS)
.collect::<String>();
bounded.push('…');
bounded
}
}
fn usage_failure_details(object: &serde_json::Map<String, Value>) -> (u16, String) {
let failure = object.get("fail").and_then(Value::as_object);
let status = failure
.and_then(|value| value.get("status_code").or_else(|| value.get("statusCode")))
.and_then(Value::as_u64)
.unwrap_or_default()
.min(u16::MAX as u64) as u16;
let body = failure
.and_then(|value| value.get("body"))
.map(usage_failure_body)
.unwrap_or_default();
(status, body)
}
fn usage_failure_is_canceled(status: u16, body: &str) -> bool {
if status == 499 {
return true;
}
let body = body.to_ascii_lowercase();
body.contains("context canceled") || body.contains("client closed request")
}
fn default_usage_generate() -> bool {
true
}
@@ -1769,9 +1931,16 @@ fn build_usage_filter(query: &UsageQuery) -> UsageSqlFilter {
"api_key_hash",
query.api_key_hash.as_deref(),
);
if let Some(canceled) = query.canceled {
clauses.push("canceled = ?".to_string());
params.push(SqlValue::Integer(i64::from(canceled)));
}
if let Some(failed) = query.failed {
clauses.push("failed = ?".to_string());
params.push(SqlValue::Integer(i64::from(failed)));
if failed {
clauses.push("failed != 0 AND canceled = 0".to_string());
} else {
clauses.push("failed = 0".to_string());
}
}
UsageSqlFilter {
clause: if clauses.is_empty() {
@@ -1818,7 +1987,8 @@ fn load_usage_overview(
SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(reasoning_tokens), 0),
@@ -1852,9 +2022,10 @@ fn load_usage_overview(
row.get::<_, i64>(7)?,
row.get::<_, i64>(8)?,
row.get::<_, i64>(9)?,
row.get::<_, f64>(10)?,
row.get::<_, Option<i64>>(11)?,
row.get::<_, i64>(10)?,
row.get::<_, f64>(11)?,
row.get::<_, Option<i64>>(12)?,
row.get::<_, Option<i64>>(13)?,
))
},
)
@@ -1868,7 +2039,8 @@ fn load_usage_overview(
local_hour,
COUNT(*),
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY local_hour
@@ -1886,7 +2058,8 @@ fn load_usage_overview(
requests: from_sql_i64(row.get(1)?),
success: from_sql_i64(row.get(2)?),
failure: from_sql_i64(row.get(3)?),
tokens: from_sql_i64(row.get(4)?),
canceled: from_sql_i64(row.get(4)?),
tokens: from_sql_i64(row.get(5)?),
})
})
.map_err(|error| format!("查询 SQLite 使用趋势失败: {error}"))?
@@ -1897,28 +2070,34 @@ fn load_usage_overview(
total_requests: from_sql_i64(summary.0),
success_count: from_sql_i64(summary.1),
failure_count: from_sql_i64(summary.2),
input_tokens: from_sql_i64(summary.3),
output_tokens: from_sql_i64(summary.4),
reasoning_tokens: from_sql_i64(summary.5),
cache_read_tokens: from_sql_i64(summary.6),
cache_creation_tokens: from_sql_i64(summary.7),
total_tokens: from_sql_i64(summary.8),
canceled_count: from_sql_i64(summary.3),
input_tokens: from_sql_i64(summary.4),
output_tokens: from_sql_i64(summary.5),
reasoning_tokens: from_sql_i64(summary.6),
cache_read_tokens: from_sql_i64(summary.7),
cache_creation_tokens: from_sql_i64(summary.8),
total_tokens: from_sql_i64(summary.9),
estimated_cost,
priced_requests,
timeline,
..UsageOverview::default()
};
if overview.total_requests > 0 {
overview.success_rate =
overview.success_count as f64 * 100.0 / overview.total_requests as f64;
let completed_requests = overview
.success_count
.saturating_add(overview.failure_count);
if completed_requests > 0 {
overview.success_rate =
overview.success_count as f64 * 100.0 / completed_requests as f64;
}
overview.average_latency_ms =
from_sql_i64(summary.9) as f64 / overview.total_requests as f64;
overview.tps = summary.10;
from_sql_i64(summary.10) as f64 / overview.total_requests as f64;
overview.tps = summary.11;
if overview.input_tokens > 0 {
overview.cache_hit_rate =
(overview.cache_read_tokens as f64 / overview.input_tokens as f64).min(1.0);
}
let minutes = query_window_minutes(query, summary.11, summary.12);
let minutes = query_window_minutes(query, summary.12, summary.13);
overview.rpm = overview.total_requests as f64 / minutes;
overview.tpm = overview.total_tokens as f64 / minutes;
}
@@ -2586,7 +2765,7 @@ fn load_simple_categories(
SELECT
COALESCE(NULLIF(TRIM({column}), ''), ?),
COUNT(*),
COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY 1
@@ -2629,7 +2808,7 @@ fn load_api_key_categories(
MAX(TRIM(api_key_remark)),
MAX(TRIM(api_key_display)),
COUNT(*),
COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY 1
@@ -2699,7 +2878,8 @@ fn load_usage_events(
api_group_key, client_ip, x_forwarded_for, user_agent, generate,
cached_tokens, collector_source,
input_tokens, output_tokens, reasoning_tokens, cache_read_tokens,
cache_creation_tokens, total_tokens
cache_creation_tokens, total_tokens, canceled, failure_status,
failure_body
FROM usage_events{}
ORDER BY timestamp_ms DESC, id DESC
LIMIT ? OFFSET ?
@@ -2739,6 +2919,9 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result<UsageRecord> {
source_display: String::new(),
auth_index: row.get(5)?,
failed: row.get::<_, i64>(6)? != 0,
canceled: row.get::<_, i64>(33)? != 0,
failure_status: row.get::<_, i64>(34)?.clamp(0, u16::MAX as i64) as u16,
failure_body: row.get(35)?,
provider: row.get(7)?,
api_group_key: row.get(20)?,
model: row.get(8)?,
@@ -3042,6 +3225,9 @@ mod tests {
source_display: String::new(),
auth_index: "auth".to_string(),
failed: false,
canceled: false,
failure_status: 0,
failure_body: String::new(),
provider: "openai".to_string(),
api_group_key: "hash".to_string(),
model: model.to_string(),
@@ -3238,6 +3424,85 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn persists_failure_details_and_excludes_client_cancellations_from_failures() {
let root = test_root("usage-failure-details");
initialize_usage_storage_at(&root).unwrap();
let config = GuiConfigFile::default();
persist_queue_items(
&root,
vec![
serde_json::json!({
"timestamp": "2026-07-29T10:00:00+08:00",
"request_id": "success",
"failed": false,
"provider": "antigravity",
"model": "gemini-test"
}),
serde_json::json!({
"timestamp": "2026-07-29T10:00:01+08:00",
"request_id": "upstream-failure",
"failed": true,
"provider": "antigravity",
"model": "gemini-test",
"fail": {
"status_code": 429,
"body": { "error": { "message": "quota exhausted" } }
}
}),
serde_json::json!({
"timestamp": "2026-07-29T10:00:02+08:00",
"request_id": "client-canceled",
"failed": true,
"provider": "antigravity",
"model": "gemini-test",
"fail": {
"status_code": 499,
"body": "context canceled"
}
}),
],
&config,
)
.unwrap();
let connection = open_usage_database_at(&root).unwrap();
let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap();
let failures = load_usage_events(
&connection,
&UsageQuery {
failed: Some(true),
..UsageQuery::default()
},
&config,
)
.unwrap();
let cancellations = load_usage_events(
&connection,
&UsageQuery {
canceled: Some(true),
..UsageQuery::default()
},
&config,
)
.unwrap();
assert_eq!(overview.total_requests, 3);
assert_eq!(overview.success_count, 1);
assert_eq!(overview.failure_count, 1);
assert_eq!(overview.canceled_count, 1);
assert_eq!(overview.success_rate, 50.0);
assert_eq!(failures.total, 1);
assert_eq!(failures.items[0].request_id, "upstream-failure");
assert_eq!(failures.items[0].failure_status, 429);
assert!(failures.items[0].failure_body.contains("quota exhausted"));
assert_eq!(cancellations.total, 1);
assert_eq!(cancellations.items[0].request_id, "client-canceled");
assert!(cancellations.items[0].canceled);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn sqlite_storage_uses_wal_and_reference_busy_timeout() {
let root = test_root("sqlite-pragmas");
@@ -3339,6 +3604,86 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn failure_detail_migration_backfills_processed_inbox_rows_once() {
let root = test_root("failure-detail-v4-migration");
let mut connection = open_test_database(&root);
let mut record = sample_record(
"legacy-canceled",
"2026-07-17T20:30:00+08:00",
"gemini-test",
);
record.failed = true;
insert_usage_records(&mut connection, &[record]).unwrap();
connection
.execute(
r#"
INSERT INTO usage_inbox (
source, message_hash, raw_message, status, attempt_count,
usage_event_key, received_at, processed_at, created_at, updated_at
) VALUES (
'test', 'legacy-canceled-hash', ?1, 'processed', 1,
'legacy-canceled', ?2, ?2, ?2, ?2
)
"#,
params![
serde_json::json!({
"request_id": "legacy-canceled",
"failed": true,
"fail": {
"status_code": 499,
"body": "client closed request"
}
})
.to_string(),
Local::now().to_rfc3339(),
],
)
.unwrap();
connection
.execute(
"DELETE FROM usage_metadata WHERE key = ?1",
params![USAGE_FAILURE_MIGRATION_KEY],
)
.unwrap();
drop(connection);
initialize_usage_storage_at(&root).unwrap();
initialize_usage_storage_at(&root).unwrap();
let connection = open_usage_database_at(&root).unwrap();
let migrated = connection
.query_row(
r#"
SELECT canceled, failure_status, failure_body
FROM usage_events
WHERE event_key = 'legacy-canceled'
"#,
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
))
},
)
.unwrap();
let markers = connection
.query_row(
"SELECT COUNT(*) FROM usage_metadata WHERE key = ?1",
params![USAGE_FAILURE_MIGRATION_KEY],
|row| row.get::<_, i64>(0),
)
.unwrap();
assert_eq!(migrated.0, 1);
assert_eq!(migrated.1, 499);
assert_eq!(migrated.2, "client closed request");
assert_eq!(markers, 1);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn durable_inbox_recovers_valid_rows_and_isolates_malformed_rows() {
let root = test_root("durable-inbox");
+9 -1
View File
@@ -113,6 +113,8 @@ export const jaOverrides = {
'usage.tab.analysis': '分析',
'usage.tab.events': 'リクエスト詳細',
'usage.tab.pricing': '料金統計',
'usage.refresh': '更新',
'usage.filter.reset': 'フィルター解除',
'usage.filter.timeRange': '期間',
'usage.range.4h': '過去 4 時間',
'usage.range.24h': '過去 24 時間',
@@ -131,12 +133,13 @@ export const jaOverrides = {
'usage.filter.allResults': 'すべての結果',
'usage.result.success': '成功',
'usage.result.failed': '失敗',
'usage.result.canceled': 'キャンセル済み',
'usage.filter.startTime': '開始日時',
'usage.filter.endTime': '終了日時',
'usage.filter.to': '~',
'usage.loading': 'ローカル SQLite の使用履歴を読み込んでいます',
'usage.stat.requests': '総リクエスト数',
'usage.stat.requestMeta': '成功 {success} · 失敗 {failed}',
'usage.stat.requestMeta': '成功 {success} · 失敗 {failed} · キャンセル {canceled}',
'usage.stat.tokens': '合計 Token',
'usage.stat.tokenMeta': '入 {input} · 出 {output}',
'usage.stat.successRate': '成功率',
@@ -188,9 +191,14 @@ export const jaOverrides = {
'usage.analysis.itemMeta': '{requests} 回 · {percent}% · {tokens} Token',
'usage.events.total': '合計 {count} 件',
'usage.events.page': '{page} / {total} ページ',
'usage.events.resetColumns': '列幅をリセット',
'usage.events.resizeHint': 'ドラッグで列幅を調整、ダブルクリックでリセット',
'usage.events.dragHint': 'ヘッダーの境界をドラッグして列幅を調整',
'usage.column.time': '日時',
'usage.column.model': 'モデル',
'usage.column.provider': 'Provider',
'usage.column.source': 'ソース',
'usage.column.key': 'API キー',
'usage.column.result': '結果',
'usage.column.latency': '合計所要時間',
'usage.column.ttft': '初回トークン遅延',
+9 -1
View File
@@ -113,6 +113,8 @@ export const en: Record<MessageKey, string> = {
'usage.tab.analysis': 'Analysis',
'usage.tab.events': 'Request Details',
'usage.tab.pricing': 'Price Statistics',
'usage.refresh': 'Refresh',
'usage.filter.reset': 'Reset Filters',
'usage.filter.timeRange': 'Time range',
'usage.range.4h': 'Last 4 Hours',
'usage.range.24h': 'Last 24 Hours',
@@ -131,12 +133,13 @@ export const en: Record<MessageKey, string> = {
'usage.filter.allResults': 'All Results',
'usage.result.success': 'Success',
'usage.result.failed': 'Failed',
'usage.result.canceled': 'Canceled',
'usage.filter.startTime': 'Start time',
'usage.filter.endTime': 'End time',
'usage.filter.to': 'to',
'usage.loading': 'Loading local SQLite usage history',
'usage.stat.requests': 'Total Requests',
'usage.stat.requestMeta': 'OK {success} · Fail {failed}',
'usage.stat.requestMeta': 'OK {success} · Fail {failed} · Canceled {canceled}',
'usage.stat.tokens': 'Total Tokens',
'usage.stat.tokenMeta': 'In {input} · Out {output}',
'usage.stat.successRate': 'Success Rate',
@@ -188,9 +191,14 @@ export const en: Record<MessageKey, string> = {
'usage.analysis.itemMeta': '{requests} requests · {percent}% · {tokens} tokens',
'usage.events.total': '{count} records',
'usage.events.page': 'Page {page} of {total}',
'usage.events.resetColumns': 'Reset Widths',
'usage.events.resizeHint': 'Drag to resize, double-click to reset',
'usage.events.dragHint': 'Drag column header borders to resize',
'usage.column.time': 'Time',
'usage.column.model': 'Model',
'usage.column.provider': 'Provider',
'usage.column.source': 'Source',
'usage.column.key': 'API Key',
'usage.column.result': 'Result',
'usage.column.latency': 'Total Duration',
'usage.column.ttft': 'First Token Latency',
+9 -1
View File
@@ -111,6 +111,8 @@ export const zhCN = {
'usage.tab.analysis': '分析',
'usage.tab.events': '请求明细',
'usage.tab.pricing': '价格统计',
'usage.refresh': '刷新数据',
'usage.filter.reset': '重置筛选',
'usage.filter.timeRange': '时间范围',
'usage.range.4h': '最近 4 小时',
'usage.range.24h': '最近 24 小时',
@@ -129,12 +131,13 @@ export const zhCN = {
'usage.filter.allResults': '全部结果',
'usage.result.success': '成功',
'usage.result.failed': '失败',
'usage.result.canceled': '已取消',
'usage.filter.startTime': '开始时间',
'usage.filter.endTime': '结束时间',
'usage.filter.to': '至',
'usage.loading': '正在读取本地 SQLite 使用记录',
'usage.stat.requests': '请求总数',
'usage.stat.requestMeta': '成功 {success} · 失败 {failed}',
'usage.stat.requestMeta': '成功 {success} · 失败 {failed} · 取消 {canceled}',
'usage.stat.tokens': 'Token 总量',
'usage.stat.tokenMeta': '入 {input} · 出 {output}',
'usage.stat.successRate': '成功率',
@@ -186,9 +189,14 @@ export const zhCN = {
'usage.analysis.itemMeta': '{requests} 次 · {percent}% · {tokens} Token',
'usage.events.total': '共 {count} 条记录',
'usage.events.page': '第 {page} / {total} 页',
'usage.events.resetColumns': '重置列宽',
'usage.events.resizeHint': '拖动调整列宽,双击重置本列',
'usage.events.dragHint': '可拖动表头边线调整每列宽度',
'usage.column.time': '时间',
'usage.column.model': '模型',
'usage.column.provider': 'Provider',
'usage.column.source': '来源',
'usage.column.key': '鉴权密钥',
'usage.column.result': '结果',
'usage.column.latency': '总耗时',
'usage.column.ttft': '首字延迟',
+1 -1
View File
@@ -1878,7 +1878,7 @@ export function AgentsPage() {
<div className="agent-config-footer">
<div className="agent-launch-control">
<div className="agent-launch-actions">
{selected === 'codex' ? (
{hasIndependentCliAndApp ? (
<>
<button
type="button"
+2 -1
View File
@@ -1076,6 +1076,7 @@ export function ApiAccessPage() {
const toggleProvider = async (row: ProviderRow) => {
setBusy(true);
setError('');
setNotice('');
try {
const latestConfig = await managementApi.get('/config');
const latestRows = sectionRecordsFromConfig(latestConfig, row.section);
@@ -1105,7 +1106,7 @@ export function ApiAccessPage() {
);
await managementApi.put(`/${row.section}`, nextRows);
}
setNotice(currentlyDisabled ? t('apiAccess.notice.enabled') : t('apiAccess.notice.disabled'));
if (!currentlyDisabled) setNotice(t('apiAccess.notice.disabled'));
await loadProviders();
} catch (requestError) {
setError(String(requestError));
File diff suppressed because it is too large. Load diff
+705 -261
View File
File diff suppressed because it is too large. Load diff