Dev (#166)
* 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:
1 parent
2b9ceb11fb
commit
3d44eba492
15 files changed
+2342
-526
No files matched your search
Generated
+3
-1
@@ -593,7 +593,9 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "cpa-gui"
|
||||
version = "0.2.59"
|
||||
|
||||
version = "0.2.61"
|
||||
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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': '初回トークン遅延',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '首字延迟',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
+1184
-167
File diff suppressed because it is too large.
Load diff
+705
-261
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user