feat: improve request pricing, model diagnostics, and client discovery

- Align response model handling with CPA-Manager-Plus and flag routing mismatches
- Add per-request costs and CSV export; fix mixed-context cost aggregation
- Display API-reported access types and move providers next to timestamps
- Use white table backgrounds and simplify cost and status displays
- Expand client discovery paths, validate executables, and prioritize installed clients
- Add model, pricing, client discovery, and UI regression tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 0e7437493d
commit dfba9b019f
20 files changed
+652 -152

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ pub(crate) fn find_antigravity_cli(home: &Path) -> Option<PathBuf> {
.join("agy/bin/agy.exe");
#[cfg(not(target_os = "windows"))]
let native = home.join(".local/bin/agy");
if native.is_file() {
if agent_cli_executable(&native) {
Some(native)
} else {
find_named_agent_executable(home, &["agy"])
+115 -27
View File
@@ -1121,12 +1121,9 @@ pub(crate) fn agent_installation_detected(
executable_found: bool,
app_installed: bool,
) -> bool {
version.is_some()
|| (matches!(
client,
AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode | AgentClient::WorkBuddy
) && executable_found)
executable_found
|| app_installed
|| (client == AgentClient::ClaudeDesktop && version.is_some())
}
pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool {
@@ -2476,12 +2473,11 @@ pub(crate) fn find_opencode_managed_executable(home: &Path) -> Option<PathBuf> {
home.join("Applications/OpenCode.app/Contents/MacOS/opencode-cli"),
]
.into_iter()
.find(|path| path.is_file())
.find(|path| agent_cli_executable(path))
}
#[cfg(not(target_os = "macos"))]
{
let path = home.join(".opencode/bin/opencode");
path.is_file().then_some(path)
find_named_agent_executable_in_directories(&[home.join(".opencode/bin")], &["opencode"])
}
}
@@ -2493,7 +2489,7 @@ pub(crate) fn find_kimi_code_executable(home: &Path) -> Option<PathBuf> {
let managed = [directory.join("kimi")];
managed
.into_iter()
.find(|path| path.is_file())
.find(|path| agent_cli_executable(path))
.or_else(|| find_named_agent_executable(home, &["kimi"]))
}
@@ -2505,7 +2501,7 @@ pub(crate) fn find_grok_build_executable(home: &Path) -> Option<PathBuf> {
let managed = [directory.join("grok")];
managed
.into_iter()
.find(|path| path.is_file())
.find(|path| agent_cli_executable(path))
.or_else(|| find_named_agent_executable(home, &["grok"]))
}
@@ -2514,18 +2510,54 @@ pub(crate) fn find_pi_executable(home: &Path) -> Option<PathBuf> {
}
pub(crate) fn find_named_agent_executable(home: &Path, names: &[&str]) -> Option<PathBuf> {
for directory in agent_executable_directories(home) {
find_named_agent_executable_in_directories(&agent_executable_directories(home), names)
}
pub(crate) fn agent_cli_executable(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(target_os = "windows")]
{
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
["exe", "cmd", "bat", "com"]
.iter()
.any(|expected| extension.eq_ignore_ascii_case(expected))
})
}
#[cfg(not(any(unix, target_os = "windows")))]
true
}
pub(crate) fn find_named_agent_executable_in_directories(
directories: &[PathBuf],
names: &[&str],
) -> Option<PathBuf> {
for directory in directories {
for name in names {
#[cfg(target_os = "windows")]
let candidates = [
directory.join(format!("{name}.exe")),
directory.join(format!("{name}.cmd")),
directory.join(format!("{name}.bat")),
directory.join(name),
directory.join(format!("{name}.com")),
];
#[cfg(not(target_os = "windows"))]
let candidates = [directory.join(name)];
if let Some(candidate) = candidates.into_iter().find(|path| path.is_file()) {
if let Some(candidate) = candidates
.into_iter()
.find(|path| agent_cli_executable(path))
{
return Some(candidate);
}
}
@@ -2534,6 +2566,18 @@ pub(crate) fn find_named_agent_executable(home: &Path, names: &[&str]) -> Option
}
pub(crate) fn agent_executable_directories(home: &Path) -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
let registered = windows_registered_path_directories();
#[cfg(not(target_os = "windows"))]
let registered = Vec::new();
agent_executable_directories_from_environment(home, |name| env::var_os(name), &registered)
}
pub(crate) fn agent_executable_directories_from_environment(
home: &Path,
environment: impl Fn(&str) -> Option<std::ffi::OsString>,
registered: &[PathBuf],
) -> Vec<PathBuf> {
let mut directories = Vec::new();
let mut push = |path: PathBuf| {
if !path.as_os_str().is_empty() && !directories.iter().any(|item| item == &path) {
@@ -2541,28 +2585,46 @@ pub(crate) fn agent_executable_directories(home: &Path) -> Vec<PathBuf> {
}
};
if let Some(path) = env::var_os("PATH") {
if let Some(path) = environment("PATH") {
env::split_paths(&path).for_each(&mut push);
}
registered.iter().cloned().for_each(&mut push);
[
home.join(".local/bin"),
home.join(".npm-global/bin"),
home.join(".bun/bin"),
home.join(".cargo/bin"),
home.join(".volta/bin"),
home.join(".local/share/pnpm"),
home.join(".pyenv/shims"),
home.join("bin"),
]
.into_iter()
.for_each(&mut push);
for variable in ["PNPM_HOME", "BUN_INSTALL", "NPM_CONFIG_PREFIX"] {
if let Some(path) = env::var_os(variable) {
for variable in [
"PNPM_HOME",
"BUN_INSTALL",
"NPM_CONFIG_PREFIX",
"npm_config_prefix",
"VOLTA_HOME",
"UV_TOOL_BIN_DIR",
"NVM_SYMLINK",
] {
if let Some(path) = environment(variable).filter(|path| !path.is_empty()) {
let path = PathBuf::from(path);
push(
if variable == "BUN_INSTALL" || variable == "NPM_CONFIG_PREFIX" {
if matches!(variable, "NPM_CONFIG_PREFIX" | "npm_config_prefix") {
push(if cfg!(target_os = "windows") {
path
} else {
path.join("bin")
});
} else {
push(if matches!(variable, "BUN_INSTALL" | "VOLTA_HOME") {
path.join("bin")
} else {
path
},
);
});
}
}
}
for root in [
@@ -2570,8 +2632,14 @@ pub(crate) fn agent_executable_directories(home: &Path) -> Vec<PathBuf> {
home.join(".local/state/fnm_multishells"),
] {
if let Ok(entries) = fs::read_dir(root) {
for entry in entries.flatten() {
push(entry.path().join("bin"));
let mut entries = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
for entry in entries.into_iter().rev() {
push(entry.join("bin"));
}
}
}
@@ -2586,11 +2654,31 @@ pub(crate) fn agent_executable_directories(home: &Path) -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
if let Some(app_data) = env::var_os("APPDATA") {
push(PathBuf::from(app_data).join("npm"));
}
if let Some(local_app_data) = env::var_os("LOCALAPPDATA") {
push(PathBuf::from(local_app_data).join("Microsoft/WindowsApps"));
let app_data = environment("APPDATA")
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Roaming"));
let local = environment("LOCALAPPDATA")
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
push(app_data.join("npm"));
push(home.join(".npm-global"));
push(local.join("Microsoft/WindowsApps"));
push(local.join("pnpm"));
push(local.join("Volta/bin"));
for root in [local.join("Programs/Python"), local.join("Python")] {
if let Ok(entries) = fs::read_dir(root) {
let mut entries = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
for entry in entries.into_iter().rev() {
push(entry.join("Scripts"));
}
}
}
}
+17
View File
@@ -212,6 +212,23 @@ fn current_version_roots() -> [RegistryRoot; 4] {
]
}
pub(crate) fn windows_registered_path_directories() -> Vec<PathBuf> {
let mut paths = Vec::new();
for (hive, key) in [
(HKEY_CURRENT_USER, r"Environment"),
(
HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
),
] {
if let Some(value) = RegistryKey::open(hive, key).and_then(|key| key.string("Path")) {
let expanded = expand_environment_strings(&value).unwrap_or(value);
paths.extend(env::split_paths(&expanded).filter(|path| path.is_absolute()));
}
}
paths
}
fn join_registry_path(prefix: &str, suffix: &str) -> String {
format!("{prefix}\\{suffix}")
}
+122
View File
@@ -1,6 +1,128 @@
use super::support::*;
use super::*;
#[test]
fn installation_detection_separates_executables_from_version_metadata() {
for client in [
AgentClient::ClaudeCode,
AgentClient::Codex,
AgentClient::OpenCode,
AgentClient::OpenClaw,
AgentClient::Hermes,
AgentClient::DeepSeekHarness,
AgentClient::KimiCode,
AgentClient::GrokBuild,
] {
assert!(agent_installation_detected(client, None, true, false));
assert!(!agent_installation_detected(client, None, false, false));
assert!(!agent_installation_detected(
client,
Some("1.0.0"),
false,
false
));
}
assert!(agent_installation_detected(
AgentClient::Codex,
None,
false,
true
));
assert!(agent_installation_detected(
AgentClient::ClaudeDesktop,
Some("1.0.0"),
false,
false,
));
}
#[test]
fn executable_directories_preserve_path_precedence_and_package_manager_locations() {
let home = agent_test_home("agent-search-environment");
let inherited = home.join("inherited");
let registered = home.join("registered");
let npm = home.join("custom-npm");
let uv = home.join("custom-uv");
let volta = home.join("custom-volta");
let directories = agent_executable_directories_from_environment(
&home,
|name| match name {
"PATH" => Some(env::join_paths([&inherited, &inherited]).unwrap()),
"NPM_CONFIG_PREFIX" => Some(npm.clone().into_os_string()),
"UV_TOOL_BIN_DIR" => Some(uv.clone().into_os_string()),
"VOLTA_HOME" => Some(volta.clone().into_os_string()),
"BUN_INSTALL" => Some(std::ffi::OsString::new()),
_ => None,
},
&[registered.clone(), inherited.clone()],
);
assert_eq!(&directories[..2], &[inherited.clone(), registered]);
assert_eq!(
directories
.iter()
.filter(|path| **path == inherited)
.count(),
1
);
let npm_bin = if cfg!(target_os = "windows") {
npm
} else {
npm.join("bin")
};
assert!(directories.contains(&npm_bin));
assert!(directories.contains(&uv));
assert!(directories.contains(&volta.join("bin")));
assert!(directories.iter().all(|path| path.is_absolute()));
fs::remove_dir_all(home).unwrap();
}
#[cfg(target_os = "windows")]
#[test]
fn windows_cli_search_skips_shell_stubs_and_finds_runnable_shims() {
let home = agent_test_home("agent-search-shims");
let first = home.join("first");
let second = home.join("second");
fs::create_dir_all(first.join("agent.exe")).unwrap();
fs::create_dir_all(&second).unwrap();
fs::write(first.join("agent"), "shell stub").unwrap();
fs::write(first.join("agent.ps1"), "powershell stub").unwrap();
let executable = second.join("agent.cmd");
fs::write(&executable, "@exit /b 1\r\n").unwrap();
assert_eq!(
find_named_agent_executable_in_directories(&[first, second], &["agent"]),
Some(executable.clone()),
);
let version = read_agent_version(&executable, &home);
assert_eq!(version, None);
assert!(agent_installation_detected(
AgentClient::ClaudeCode,
version.as_deref(),
agent_cli_executable(&executable),
false,
));
fs::remove_dir_all(home).unwrap();
}
#[cfg(unix)]
#[test]
fn unix_cli_search_requires_executable_permissions() {
use std::os::unix::fs::PermissionsExt;
let home = agent_test_home("agent-search-permissions");
let executable = home.join("agent");
fs::write(&executable, "exit 1\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(
find_named_agent_executable_in_directories(&[home.clone()], &["agent"]),
None
);
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
find_named_agent_executable_in_directories(&[home.clone()], &["agent"]),
Some(executable)
);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn configuration_paths_ignore_inherited_environment() {
let home = agent_test_home("isolated-paths");
+116 -12
View File
@@ -224,8 +224,11 @@ pub(crate) struct UsageRecord {
api_group_key: String,
#[serde(default)]
model: String,
#[serde(default)]
#[serde(default, alias = "responseModel")]
response_model: String,
// Derived from the active price catalog when reading a page; never trust imported estimates.
#[serde(default, skip_deserializing)]
cost: Option<UsageRecordCost>,
#[serde(default)]
alias: String,
#[serde(default, skip_serializing)]
@@ -264,6 +267,12 @@ pub(crate) struct UsageRecord {
tokens: UsageTokenStats,
}
#[derive(Clone, Serialize)]
struct UsageRecordCost {
total: f64,
pricing_model: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyUsageHourFile {
@@ -2765,9 +2774,16 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result<UsageR
failure_body,
provider,
api_group_key,
model: string_field(object, "model").unwrap_or_else(|| "unknown".to_string()),
response_model: string_field(object, "response_model").unwrap_or_default(),
alias: string_field(object, "alias").unwrap_or_default(),
model: string_field(object, "resolved_model")
.or_else(|| string_field(object, "resolvedModel"))
.or_else(|| string_field(object, "model"))
.unwrap_or_else(|| "unknown".to_string()),
response_model: string_field(object, "response_model")
.or_else(|| string_field(object, "responseModel")).unwrap_or_default(),
cost: None,
alias: string_field(object, "alias")
.or_else(|| string_field(object, "requested_model"))
.or_else(|| string_field(object, "requestedModel")).unwrap_or_default(),
client_ip: string_field(object, "client_ip"),
x_forwarded_for: string_field(object, "x_forwarded_for"),
user_agent: string_field(object, "user_agent"),
@@ -3234,19 +3250,49 @@ fn cost_for_price(model: &str, service_tier: &str, tokens: &CostTokens, price: &
long_output_multiplier,
);
let tier = service_tier.trim().to_ascii_lowercase();
let multiplier = if tokens.long_input > 0
&& matches!(tier.as_str(), "priority" | "fast")
let multiplier = match tier.as_str() {
"flex" | "batch" => 0.5,
"priority" | "fast" => service_tier_multiplier(model),
_ => 1.0,
};
// A grouped total can contain both context lengths. Long-context overrides
// must not suppress the service-tier multiplier on its short requests.
let long_multiplier = if matches!(tier.as_str(), "priority" | "fast")
&& !is_model_family(model, "gpt-6")
{
1.0
} else {
match tier.as_str() {
"flex" | "batch" => 0.5,
"priority" | "fast" => service_tier_multiplier(model),
_ => 1.0,
}
multiplier
};
(short_cost + long_cost) * multiplier
short_cost * multiplier + long_cost * long_multiplier
}
fn usage_record_cost(record: &UsageRecord, prices: &HashMap<String, ModelPrice>) -> Option<UsageRecordCost> {
let (model, price) = resolve_model_price(&record.model, &record.alias, prices)?;
let input = record.tokens.input_tokens;
// Keep the same context buckets and tier selection as the aggregate pricing query.
let long = input > LONG_CONTEXT_INPUT_TOKEN_THRESHOLD
|| (input > GROK_47_LONG_CONTEXT_INPUT_TOKEN_THRESHOLD && is_model_family(model, "grok-4.7"));
let tokens = CostTokens {
input,
output: record.tokens.output_tokens,
cache_read: record.tokens.cache_read_tokens,
cache_creation: record.tokens.cache_creation_tokens,
long_input: if long { input } else { 0 },
long_output: if long { record.tokens.output_tokens } else { 0 },
long_cache_read: if long { record.tokens.cache_read_tokens } else { 0 },
long_cache_creation: if long { record.tokens.cache_creation_tokens } else { 0 },
};
let identity = format!("{} {} {}", record.executor_type, record.provider, record.auth_type).to_ascii_lowercase();
let tier = if identity.contains("codex") || record.response_service_tier.trim().is_empty() {
&record.service_tier
} else {
&record.response_service_tier
};
Some(UsageRecordCost {
total: cost_for_price(model, tier, &tokens, &price),
pricing_model: price.model,
})
}
fn cost_for_token_segment(
@@ -4243,8 +4289,10 @@ fn load_usage_events(
.map_err(|error| format!("Failed to query SQLite usage events: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("Failed to read SQLite usage events: {error}"))?;
let prices = load_model_prices(connection)?;
for item in &mut items {
item.source_display = usage_source_display(config, &item.provider, &item.source);
item.cost = usage_record_cost(item, &prices);
}
Ok(UsageEventPage {
items,
@@ -4273,6 +4321,7 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result<UsageRecord> {
api_group_key: row.get(20)?,
model: row.get(8)?,
response_model: row.get(37)?,
cost: None,
alias: row.get(9)?,
client_ip: row.get(21)?,
x_forwarded_for: row.get(22)?,
@@ -5044,6 +5093,7 @@ mod tests {
api_group_key: "hash".to_string(),
model: model.to_string(),
response_model: String::new(),
cost: None,
alias: String::new(),
client_ip: None,
x_forwarded_for: None,
@@ -6586,6 +6636,60 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn response_model_accepts_manager_plus_field_names() {
let record = normalize_usage_record(serde_json::json!({
"request_id": "compatible-models", "requestedModel": "client-alias",
"resolvedModel": "gpt-6-sol", "model": "display-model",
"responseModel": " reported-model "
}), &GuiConfigFile::default()).unwrap();
assert_eq!(record.alias, "client-alias");
assert_eq!(record.model, "gpt-6-sol");
assert_eq!(record.response_model, "reported-model");
}
#[test]
fn request_cost_matches_aggregate_and_recalculates_current_prices() {
let mut connection = Connection::open_in_memory().unwrap();
initialize_usage_schema(&connection).unwrap();
let mut records = Vec::new();
for (index, model) in ["gpt-6-sol", "gpt-5.6-terra", "xai/grok-4.7"].iter().enumerate() {
for input in [200_000, 250_000, 300_000] {
let mut record = sample_record(&format!("{index}-{input}"), "2026-10-04T00:00:00Z", model);
record.provider = if index < 2 { "codex" } else { "openai" }.into();
record.service_tier = "fast".into();
record.response_service_tier = "flex".into();
record.tokens.input_tokens = input;
record.tokens.output_tokens = 500;
record.tokens.reasoning_tokens = 100;
record.tokens.cache_read_tokens = 100_000;
record.tokens.cache_creation_tokens = 1_000;
record.tokens.total_tokens = input + 500;
record.response_model = "unpriced-response-must-not-change-billing".into();
records.push(record);
}
}
records.push(sample_record("unknown-price", "2026-10-04T00:00:00Z", "unpriced-model"));
insert_usage_records(&mut connection, &records).unwrap();
let query = UsageQuery::default();
let page = load_usage_events(&connection, &query, &GuiConfigFile::default()).unwrap();
let (aggregate, priced) = load_estimated_cost(&connection, &build_usage_filter(&query)).unwrap();
let total: f64 = page.items.iter().filter_map(|r| r.cost.as_ref().map(|c| c.total)).sum();
assert!((aggregate - total).abs() < 1e-9);
assert_eq!(priced, 9);
assert!(page.items.iter().find(|r| r.id == "unknown-price").unwrap().cost.is_none());
let free_price = ModelPrice { model: "unpriced-model".into(), prompt_configured: true,
completion_configured: true, cache_read_configured: true, cache_creation_configured: true,
source: "manual".into(), ..ModelPrice::default() };
upsert_model_price(&connection, &free_price).unwrap();
let updated = load_usage_events(&connection, &query, &GuiConfigFile::default()).unwrap();
let free = updated.items.iter().find(|r| r.id == "unknown-price").unwrap();
assert_eq!(free.cost.as_ref().unwrap().total, 0.0);
let imported: UsageRecord = serde_json::from_value(serde_json::to_value(free).unwrap()).unwrap();
assert!(imported.cost.is_none(), "Imported estimates must be recomputed");
}
#[test]
fn estimates_gpt56_cost_with_cache_and_service_tier_rules() {
let terra = official_model_price("openai/gpt-5.6-terra").unwrap();
+11 -3
View File
@@ -731,8 +731,16 @@ export const jaOverrides = {
'usage.column.time': '日時',
'usage.column.model': 'モデル',
'usage.model.request': 'リクエストモデル',
'usage.model.upstream': '上流モデル',
'usage.model.response': '上流レスポンス',
'usage.model.upstream': 'ルーティングモデル',
'usage.model.response': '応答モデル',
'usage.column.cost': '今回の費用',
'usage.cost.estimate': '推定 · USD',
'usage.cost.unpriced': '価格未設定',
'usage.cost.hint': '現在の価格設定による USD 推定額。料金モデル:{model}。入力、出力、キャッシュ読み書き、サービス階層と長文ルールを含みます。実際の請求額ではありません。',
'usage.model.mismatch': 'モデル不一致',
'usage.model.responseHint': '使用量 API の response_model(上流が申告したモデル名であり、モデルの実体を検証したものではありません)。',
'usage.provider.hint': '使用量 API の provider と auth_type に基づく接続先と認証方式です。モデル名から提供元を推測しません。',
'usage.provider.unknownAccess': '接続方式の記録なし',
'usage.column.effort': '推論レベル',
'usage.column.request': 'リクエスト',
'usage.column.tokens': 'Token',
@@ -1089,7 +1097,7 @@ export const jaOverrides = {
'agents.clients.page': '{page} / {total} ページ',
'agents.clients.listed': 'クライアント {count} 件',
'agents.clients.manage': 'クライアントを管理',
'agents.clients.description': '一覧に残すクライアントを選択して保存します。順序は固定で、収まらない場合はページに分けて表示します。クライアントをクリックすると設定が開きます。',
'agents.clients.description': '一覧に残すクライアントを選択して保存します。インストール済みを先に表示し、各グループ内の順序は固定です。収まらない場合はページに分けて表示します。クリックすると設定が開きます。',
'agents.clients.search': 'クライアントを検索',
'agents.clients.selected': '{count} 件のクライアントを選択中',
'agents.clients.automatic': '自動表示に戻す',
+11 -3
View File
@@ -719,8 +719,16 @@ export const en: Record<MessageKey, string> = {
'usage.column.time': 'Time',
'usage.column.model': 'Model',
'usage.model.request': 'Requested model',
'usage.model.upstream': 'Upstream model',
'usage.model.response': 'Upstream response',
'usage.model.upstream': 'Routed model',
'usage.model.response': 'Response model',
'usage.column.cost': 'Request cost',
'usage.cost.estimate': 'Estimated · USD',
'usage.cost.unpriced': 'Unpriced',
'usage.cost.hint': 'Estimated USD using current pricing. Pricing model: {model}; includes input, output, cache reads and writes, service tier and long-context rules. Not an invoice.',
'usage.model.mismatch': 'Model mismatch',
'usage.model.responseHint': 'From usage API response_model: the model name declared upstream, not a verified model identity.',
'usage.provider.hint': 'From usage API provider and auth_type: the route and authentication method, not a vendor inferred from the model name.',
'usage.provider.unknownAccess': 'Access type unrecorded',
'usage.column.effort': 'Reasoning Effort',
'usage.column.request': 'Request',
'usage.column.tokens': 'Token',
@@ -1089,7 +1097,7 @@ export const en: Record<MessageKey, string> = {
'agents.clients.page': 'Page {page} / {total}',
'agents.clients.listed': '{count} clients',
'agents.clients.manage': 'Manage Clients',
'agents.clients.description': 'Choose the clients to keep in the list, then save. Clients stay in a fixed order across pages. Click a client to open its settings.',
'agents.clients.description': 'Choose the clients to keep in the list, then save. Installed clients come first, with a fixed order within each group across pages. Click a client to open its settings.',
'agents.clients.search': 'Search clients',
'agents.clients.selected': '{count} clients selected',
'agents.clients.automatic': 'Restore automatic display',
+11 -3
View File
@@ -718,8 +718,16 @@ export const zhCN = {
'usage.column.time': '时间',
'usage.column.model': '模型',
'usage.model.request': '请求模型',
'usage.model.upstream': '上游模型',
'usage.model.response': '上游响应',
'usage.model.upstream': '路由模型',
'usage.model.response': '响应模型',
'usage.column.cost': '本次费用',
'usage.cost.estimate': '估算 · USD',
'usage.cost.unpriced': '未定价',
'usage.cost.hint': '按当前价格统计配置估算(USD)。计价模型:{model};包含输入、输出、缓存读取及写入,应用服务层级与长上下文规则,非实际账单。',
'usage.model.mismatch': '模型不一致',
'usage.model.responseHint': '来自用量 API 的 response_model(上游声明的模型名,不代表已验证模型身份)。',
'usage.provider.hint': '来自用量 API 的 provider 与 auth_type,表示接入通道和认证方式,不按模型名推断厂商。',
'usage.provider.unknownAccess': '未记录接入方式',
'usage.column.effort': '推理强度',
'usage.column.request': '请求',
'usage.column.tokens': 'Token',
@@ -1088,7 +1096,7 @@ export const zhCN = {
'agents.clients.page': '第 {page} / {total} 页',
'agents.clients.listed': '{count} 个客户端',
'agents.clients.manage': '管理客户端',
'agents.clients.description': '勾选要保留在列表中的客户端,保存后生效。列表顺序固定,放不下时分页显示;点击客户端切换配置。',
'agents.clients.description': '勾选要保留在列表中的客户端,保存后生效。已安装的客户端优先,同组内顺序固定,放不下时分页显示;点击客户端切换配置。',
'agents.clients.search': '搜索客户端',
'agents.clients.selected': '已选择 {count} 个客户端',
'agents.clients.automatic': '恢复自动显示',
+6 -4
View File
@@ -47,10 +47,12 @@ export function AgentClientList<Id extends string>({
const close = () => setManaging(false);
const [draftIds, setDraftIds] = useState<Id[] | null>(null);
const [query, setQuery] = useState('');
// Stable sorting keeps the existing order within each installation group.
const orderedClients = [...clients].sort((left, right) => Number(right.installed) - Number(left.installed));
// Keep the current client reachable even when detection fails or it was uninstalled.
const automaticIds = clients.filter((client) => client.detected || client.id === selected)
const automaticIds = orderedClients.filter((client) => client.detected || client.id === selected)
.map((client) => client.id);
const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
const visibleClients = orderedClients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
const pageCount = Math.max(1, Math.ceil(visibleClients.length / capacity));
const currentPage = Math.min(page, pageCount - 1);
const pageClients = visibleClients.slice(currentPage * capacity, (currentPage + 1) * capacity);
@@ -101,13 +103,13 @@ export function AgentClientList<Id extends string>({
}
setVisibleIds(draftIds);
if (!draftSelection.includes(selected)) {
const first = clients.find((client) => draftSelection.includes(client.id));
const first = orderedClients.find((client) => draftSelection.includes(client.id));
if (first) onSelect(first.id);
}
setManaging(false);
};
const matches = clients.filter((client) => client.name.toLowerCase().includes(query.trim().toLowerCase()));
const matches = orderedClients.filter((client) => client.name.toLowerCase().includes(query.trim().toLowerCase()));
return <>
<aside className="panel agent-client-list" aria-label={t('agents.localClients')}>
+82 -51
View File
@@ -1,27 +1,27 @@
/* The request log is deliberately scoped away from the other usage tabs. */
.usage-records-page .usage-request-log {
--usage-log-surface: var(--bg-card);
--usage-log-text: #292724;
--usage-log-muted: #77736d;
--usage-log-line: #e5e4e2;
--usage-log-hover: #fafaf9;
--usage-log-soft: #f7f7f6;
--usage-log-input: #0076b5;
--usage-log-output: #15803d;
--usage-log-reasoning: #7c3aed;
--usage-log-cache-read: #b45309;
--usage-log-cache-write: #be185d;
--usage-log-response-model: #c2410c;
--usage-log-surface: #fff;
--usage-log-text: #24343b;
--usage-log-muted: #63767d;
--usage-log-line: #e8e8e8;
--usage-log-hover: #f7f7f7;
--usage-log-soft: #f5f5f5;
--usage-log-input: #526b76;
--usage-log-output: #16756a;
--usage-log-reasoning: #526b76;
--usage-log-cache-read: #16756a;
--usage-log-cache-write: #63767d;
--usage-log-response-model: #9a5808;
display: flex;
min-width: 0;
min-height: 0;
padding: 0;
gap: 0;
border: 1px solid var(--usage-log-line);
border-radius: 16px;
border-radius: 8px;
background: var(--usage-log-surface);
color: var(--usage-log-text);
box-shadow: 0 2px 10px rgb(0 0 0 / 2%);
box-shadow: none;
overflow: hidden;
}
@@ -43,7 +43,7 @@
gap: 6px;
padding: 0 10px;
border: 1px solid var(--usage-log-line);
border-radius: 999px;
border-radius: 6px;
background: var(--usage-log-surface);
color: var(--usage-log-muted);
box-shadow: none;
@@ -103,17 +103,17 @@
}
.usage-request-log .usage-events-table th {
height: 36px;
height: 40px;
padding: 0;
background: var(--usage-log-surface);
color: var(--usage-log-muted);
box-shadow: none;
font-size: 10px;
font-weight: 500;
font-size: 11px;
font-weight: 600;
}
.usage-request-log .usage-events-table th .usage-th-content {
height: 36px;
height: 40px;
justify-content: flex-start;
padding: 0 8px;
text-align: left;
@@ -122,6 +122,7 @@
.usage-request-log .usage-events-table td {
height: 68px;
padding: 9px 8px;
background: var(--usage-log-surface);
color: var(--usage-log-text);
line-height: 1.45;
}
@@ -130,6 +131,21 @@
background: var(--usage-log-hover) !important;
}
.usage-request-log .usage-th-total,
.usage-request-log .usage-td-total {
border-left: 1px solid var(--usage-log-line);
}
.usage-request-log .usage-access-type {
display: block;
width: fit-content;
max-width: 100%;
padding: 1px 5px;
border: 1px solid var(--usage-log-line);
border-radius: 3px;
letter-spacing: .02em;
}
.usage-request-log .usage-events-table td strong {
color: var(--usage-log-text);
font-weight: 600;
@@ -149,6 +165,21 @@
color: var(--usage-log-response-model);
}
.usage-request-log .usage-model-mismatch {
display: inline-block;
margin-top: 3px;
padding: 1px 5px;
border: 1px solid color-mix(in srgb, var(--usage-log-response-model) 30%, transparent);
border-radius: 3px;
color: var(--usage-log-response-model);
font-size: 10px;
line-height: 1.4;
}
.usage-request-log .usage-td-cost strong {
font-variant-numeric: tabular-nums;
}
.usage-request-log .usage-events-table .usage-td-key > strong {
font-family: var(--font-family-mono);
font-size: 11px;
@@ -175,7 +206,8 @@
gap: 9px;
margin-top: 3px;
font-size: 10px;
font-weight: 600;
font-weight: 500;
font-family: var(--font-family-mono);
line-height: 1.45;
white-space: nowrap;
}
@@ -199,43 +231,41 @@
.usage-request-log .tone-cache-read { color: var(--usage-log-cache-read); }
.usage-request-log .tone-cache-write { color: var(--usage-log-cache-write); }
.usage-request-log .usage-tag-pill {
padding: 0;
border-radius: 0;
background: transparent;
color: var(--usage-log-text);
font-size: 12px;
font-weight: 500;
}
.usage-request-log .usage-result {
min-width: 48px;
justify-content: center;
padding: 3px 11px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 10px;
min-width: 0;
justify-content: flex-start;
gap: 6px;
padding: 0;
border: 0;
border-radius: 0;
font-size: 11px;
font-weight: 600;
line-height: 1.35;
}
.usage-request-log .usage-result-dot { display: none; }
.usage-request-log .usage-result-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 1px;
background: currentColor;
box-shadow: none;
}
.usage-request-log .usage-result.success {
border-color: #6ee7b7;
background: #d1fae5;
color: #047857;
background: transparent;
color: #16756a;
}
.usage-request-log .usage-result.failed {
border-color: var(--failure-badge-border);
background: var(--failure-badge-bg);
background: transparent;
color: var(--failure-badge-text);
}
.usage-request-log .usage-result.canceled {
border-color: var(--usage-log-line);
background: var(--usage-log-soft);
background: transparent;
color: var(--usage-log-muted);
}
@@ -282,13 +312,13 @@
.usage-request-log .usage-events-footer {
display: flex;
min-height: 62px;
min-height: 56px;
flex: none;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 18px;
padding: 10px 14px;
border-top: 1px solid var(--usage-log-line);
background: var(--usage-log-surface);
}
@@ -306,7 +336,7 @@
height: 32px;
padding: 0 11px;
border: 1px solid var(--usage-log-line);
border-radius: 999px;
border-radius: 6px;
background: var(--usage-log-surface);
color: var(--usage-log-text);
font-size: 11px;
@@ -324,17 +354,18 @@
}
:root[data-theme='dark'] .usage-records-page .usage-request-log {
--usage-log-surface: var(--bg-card);
--usage-log-text: #edece9;
--usage-log-muted: #aaa8a4;
--usage-log-line: #34363b;
--usage-log-hover: #26282c;
--usage-log-soft: #292b30;
--usage-log-input: #38bdf8;
--usage-log-output: #4ade80;
--usage-log-reasoning: #b69cff;
--usage-log-cache-read: #fbbf24;
--usage-log-cache-write: #f472b6;
--usage-log-response-model: #fb923c;
--usage-log-input: #adc0ca;
--usage-log-output: #77cbbb;
--usage-log-reasoning: #adc0ca;
--usage-log-cache-read: #77cbbb;
--usage-log-cache-write: #aaa8a4;
--usage-log-response-model: #f0bc73;
border-color: var(--usage-log-line);
background: var(--usage-log-surface);
box-shadow: none;
@@ -342,7 +373,7 @@
:root[data-theme='dark'] .usage-request-log .usage-result.success {
border-color: #17664e;
background: #123c30;
background: transparent;
color: #6ee7b7;
}
+46 -21
View File
@@ -5,6 +5,8 @@ import type { MessageKey } from '../i18n/resources';
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
import { formatDuration, formatUsageNumber } from '../services/usageNumber';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import { usageProviderDetails } from '../services/usageProvider';
import { usageModelDetails } from '../services/usageModel';
const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale());
const compactDuration = (value: number) => formatDuration(value, getCurrentLocale());
@@ -29,9 +31,11 @@ export type UsageRecord = {
failure_status: number;
failure_body: string;
provider: string;
auth_type?: string;
model: string;
/** Model name reported by the upstream response, when available. */
response_model?: string;
cost?: { total: number; pricing_model: string } | null;
alias: string;
reasoning_effort: string;
endpoint: string;
@@ -63,6 +67,7 @@ type EventColumnKey =
| 'source'
| 'key'
| 'cache'
| 'cost'
| 'total'
| 'result'
| 'latency'
@@ -80,6 +85,7 @@ type EventColumnDef = {
const EVENT_COLUMNS: readonly EventColumnDef[] = [
{ key: 'time', labelKey: 'usage.column.time', defaultWidth: 84, minWidth: 76, align: 'left' },
{ key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' },
{ key: 'key', labelKey: 'usage.column.key', defaultWidth: 120, minWidth: 96, align: 'left' },
{ key: 'source', labelKey: 'usage.column.source', defaultWidth: 180, minWidth: 120, align: 'left' },
{ key: 'model', labelKey: 'usage.column.model', defaultWidth: 132, minWidth: 104, align: 'left' },
@@ -90,11 +96,11 @@ const EVENT_COLUMNS: readonly EventColumnDef[] = [
{ key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 88, minWidth: 72, align: 'left' },
{ key: 'total', labelKey: 'usage.column.tokens', defaultWidth: 120, minWidth: 112, align: 'left' },
{ key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 104, minWidth: 88, align: 'left' },
{ key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' },
{ key: 'cost', labelKey: 'usage.column.cost', defaultWidth: 112, minWidth: 96, align: 'left' },
] as const;
const DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [
'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider',
'time', 'provider', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'cost',
];
const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
'time', 'model', 'input', 'output', 'cache', 'cacheRate', 'total', 'speed', 'ttft', 'latency', 'result', 'provider', 'source',
@@ -105,16 +111,18 @@ const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1'
const PREVIOUS_EVENT_COLUMN_WIDTHS: Record<EventColumnKey, number> = {
time: 105, key: 150, source: 205, model: 150, effort: 100, result: 95,
request: 145, latency: 125, speed: 110, total: 145, cache: 135, provider: 135,
cost: 112,
};
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v3';
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v2';
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v4';
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v3';
const getAllEventColumnKeys = () => EVENT_COLUMNS.map((column) => column.key);
const getInitialVisibleColumns = (): EventColumnKey[] => {
try {
const currentRaw = localStorage.getItem(EVENT_VISIBLE_COLS_STORAGE_KEY);
const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY);
const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY)
?? localStorage.getItem('cpa-gui.usage-events-visible-cols.v2');
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
@@ -131,7 +139,8 @@ const getInitialVisibleColumns = (): EventColumnKey[] => {
const wasLegacyDefault = currentRaw === null
&& parsed.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key));
return wasLegacyDefault ? [...DEFAULT_EVENT_VISIBLE_COLUMNS] : savedKeys;
if (wasLegacyDefault) return [...DEFAULT_EVENT_VISIBLE_COLUMNS];
return currentRaw === null && !savedKeys.includes('cost') ? [...savedKeys, 'cost'] : savedKeys;
}
}
}
@@ -288,34 +297,50 @@ function UsageEventCell({
</td>
);
case 'model': {
const responseModel = (record.response_model ?? '').trim();
const modelTitle = responseModel ? [
`${t('usage.model.request')}: ${record.alias || record.model}`,
`${t('usage.model.upstream')}: ${record.model}`,
`${t('usage.model.response')}: ${responseModel}`,
].join('\n') : undefined;
const model = usageModelDetails(record.model, record.alias, record.response_model);
const modelTitle = [
`${t('usage.model.request')}: ${model.requested}`,
model.showResolved ? `${t('usage.model.upstream')}: ${model.resolved}` : '',
model.showResponseInTooltip ? `${t('usage.model.response')}: ${model.response}` : '',
model.mismatch ? t('usage.model.mismatch') : '',
model.showResponseInTooltip ? t('usage.model.responseHint') : '',
].filter(Boolean).join('\n');
return (
<td className="usage-stacked-cell usage-td-model align-left" title={modelTitle}>
<strong title={modelTitle || record.alias || record.model}>{record.alias || record.model}</strong>
{record.alias && record.alias !== record.model ? <small title={modelTitle || record.model}>{record.model}</small> : null}
{responseModel ? (
<strong title={modelTitle}>{model.requested}</strong>
{model.showResolved ? <small title={modelTitle}>{model.resolved}</small> : null}
{model.mismatch ? (
<small className="usage-response-model" title={modelTitle}>
<span aria-hidden="true">↳ </span>{t('usage.model.response')}: {responseModel}
{t('usage.model.response')}: {model.response}
</small>
) : null}
{model.mismatch ? <span className="usage-model-mismatch">{t('usage.model.mismatch')}</span> : null}
</td>
);
}
case 'cost': {
const cost = record.cost;
const amount = cost ? new Intl.NumberFormat(getCurrentLocale(), {
style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol', minimumFractionDigits: 2, maximumFractionDigits: 6,
}).format(cost.total) : '—';
return <td className="usage-td-cost usage-stacked-cell align-left" title={cost ? t('usage.cost.hint', { model: cost.pricing_model }) : t('usage.cost.unpriced')}>
<strong>{amount}</strong>
{!cost ? <small>{t('usage.cost.unpriced')}</small> : null}
</td>;
}
case 'effort':
return <td className="usage-stacked-cell align-left" title={record.reasoning_effort || 'auto'}><strong>{record.reasoning_effort || 'auto'}</strong></td>;
case 'request':
return <td className="usage-stacked-cell align-left" title={record.endpoint || undefined}><strong>{record.endpoint || '—'}</strong></td>;
case 'provider':
case 'provider': {
const provider = usageProviderDetails(record.provider, record.auth_type);
return (
<td className="usage-td-provider align-left" title={record.provider || undefined}>
<span className="usage-tag-pill">{record.provider || '—'}</span>
<td className="usage-td-provider usage-stacked-cell align-left" title={`${t('usage.provider.hint')}\nprovider: ${provider.rawProvider || '—'}\nauth_type: ${provider.rawAuthType || '—'}`}>
<strong>{provider.name}</strong>
<small className="usage-access-type">{provider.access || t('usage.provider.unknownAccess')}</small>
</td>
);
}
case 'source':
return (
<td className="usage-td-source align-left" title={record.source_display || record.source || undefined}>
@@ -548,13 +573,13 @@ export function EventsView({
const endRecordNum = Math.min(events.page * pageSize, events.total);
const exportCurrentPage = () => {
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens'];
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens', 'estimated_cost_usd', 'pricing_model'];
const csvCell = (value: string | number | boolean | null) => {
const text = value == null ? '' : String(value);
const safe = typeof value === 'string' && /^[\s\u0000-\u001f]*[=+@-]/.test(text) ? `'${text}` : text;
return `"${safe.replace(/"/g, '""')}"`;
};
const rows = events.items.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens]);
const rows = events.items.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens, record.cost?.total ?? null, record.cost?.pricing_model ?? '']);
const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
const link = document.createElement('a');
+12
View File
@@ -0,0 +1,12 @@
/** Match CPA-Manager-Plus request monitoring: compare the response with the routed model. */
export function usageModelDetails(model: string, alias = '', response = '') {
const resolved = model.trim();
const requested = alias.trim() || resolved || '—';
const responseModel = response.trim();
return {
requested, resolved, response: responseModel,
showResolved: Boolean(resolved && resolved !== requested),
mismatch: Boolean(responseModel && resolved && responseModel !== resolved),
showResponseInTooltip: Boolean(responseModel && (!resolved || responseModel !== resolved)),
};
}
+18
View File
@@ -0,0 +1,18 @@
/** Usage API values describe the route, not the brand implied by a model name. */
export function usageProviderDetails(provider: string, authType = '') {
const rawProvider = provider.trim();
const rawAuthType = authType.trim();
const labels: Record<string, string> = {
codex: 'Codex', claude: 'Claude', gemini: 'Gemini', aistudio: 'AI Studio',
antigravity: 'Antigravity', openai: 'OpenAI', 'openai-compatibility': 'OpenAI Compatible',
vertex: 'Vertex AI', 'vertex-ai': 'Vertex AI', xai: 'xAI', grok: 'Grok',
};
return {
name: labels[rawProvider.toLowerCase()] ?? (rawProvider || '—'),
access: rawAuthType.toLowerCase() === 'apikey' ? 'API'
: rawAuthType.toLowerCase() === 'oauth' ? 'OAuth'
: rawAuthType && rawAuthType.toLowerCase() !== 'unknown' ? rawAuthType : '',
rawProvider,
rawAuthType,
};
}
+16 -3
View File
@@ -8,7 +8,7 @@ const path = require('node:path');
const base = process.env.AGENT_CLIENT_TEST_URL || 'http://127.0.0.1:1421';
const storageKey = 'cpa-gui.agent-visible-clients.v1';
const automaticClients = ['Claude Code', 'Claude Desktop', 'Codex', 'OpenCode', 'Pi'];
const automaticClients = ['Claude Code', 'Codex', 'OpenCode', 'Claude Desktop', 'Pi'];
const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
(async () => {
@@ -89,6 +89,19 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
assert.equal(await page.locator('.agent-list-items button.active').getAttribute('aria-pressed'), 'true');
assert.equal(await stored(), null, 'automatic defaults do not become a custom preference');
// Sorting applies to custom lists and detection refresh, without changing membership or selection.
const customIds = ['claude-desktop', 'codex', 'hermes'];
await open('mixed-clients', JSON.stringify(customIds));
await waitClients(['Codex', 'Claude Desktop', 'Hermes Agent']);
assert.deepEqual(await page.locator('.agent-list-items strong').allTextContents(), ['Codex', 'Claude Desktop', 'Hermes Agent']);
await refresh({ hermes: { installed: true } });
await waitClients(['Codex', 'Hermes Agent', 'Claude Desktop']);
assert.deepEqual(await page.locator('.agent-list-items strong').allTextContents(), ['Codex', 'Hermes Agent', 'Claude Desktop']);
assert.equal(await activeClient(), 'Codex');
assert.deepEqual(JSON.parse(await stored()), customIds, 'sorting must not rewrite saved membership');
await open();
await waitClients(automaticClients);
// Search reaches uninstalled clients; a cancelled draft never affects navigation or storage.
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 13);
@@ -149,12 +162,12 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
await automatic().click();
await closeAndFocus(() => save().click());
assert.equal(await stored(), null, 'saving automatic mode clears the custom preference');
await waitClients([...automaticClients, 'Kimi Code', 'Hermes Agent']);
await waitClients(['Claude Code', 'Codex', 'OpenCode', 'Kimi Code', 'Claude Desktop', 'Pi', 'Hermes Agent']);
await refresh({ ...override,
'claude-code': { installed: false, configured: false, configExists: false },
openclaw: { installed: true },
});
await waitClients(['Claude Desktop', 'Codex', 'OpenCode', 'Pi', 'Kimi Code', 'OpenClaw', 'Hermes Agent']);
await waitClients(['Codex', 'OpenCode', 'Kimi Code', 'OpenClaw', 'Claude Desktop', 'Pi', 'Hermes Agent']);
// Detection failure and a clean machine still offer a usable selected client and the full catalog.
await open('not-installed&no-plugin&fresh');
+1
View File
@@ -55,6 +55,7 @@ const records = Array.from({ length: 400 }, (_, index) => ({
failure_status: index % 4 === 0 ? 429 : 0,
failure_body: index % 4 === 0 ? 'Rate limit exceeded' : '',
provider: 'test-provider',
auth_type: index % 2 ? 'oauth' : 'apikey',
model: index % 3 ? 'test-model' : 'secondary-model',
alias: '',
reasoning_effort: 'high',
+4 -1
View File
@@ -12,6 +12,7 @@ localStorage.setItem('cpa-gui.usage-events-visible-cols.v3', JSON.stringify([
'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider',
]));
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v2');
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v4');
localStorage.removeItem('cpa-gui.usage-events-col-widths.v2');
document.documentElement.dataset.theme = params.get('theme') || 'light';
@@ -36,7 +37,9 @@ const record = (id: string, model: string, alias: string, response_model?: strin
canceled: false,
failure_status: 0,
failure_body: '',
provider: 'test-provider',
provider: 'codex',
auth_type: id === 'differing' ? 'apikey' : id === 'matching' ? 'oauth' : undefined,
cost: id === 'differing' ? { total: 0.0042, pricing_model: 'gpt-6-astra' } : id === 'matching' ? { total: 0, pricing_model: 'gpt-6-sol' } : null,
model,
alias,
...(response_model === undefined ? {} : { response_model }),
+32 -20
View File
@@ -6,10 +6,10 @@ const path = require('node:path');
const base = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:1421';
const locales = {
'zh-CN': { request: '请求模型', upstream: '上游模型', response: '上游响应' },
en: { request: 'Requested model', upstream: 'Upstream model', response: 'Upstream response' },
ja: { request: 'リクエストモデル', upstream: '上流モデル', response: '上流レスポンス' },
'zh-TW': { request: '請求模型', upstream: '上游模型', response: '上游響應' },
'zh-CN': { request: '请求模型', upstream: '路由模型', response: '响应模型' },
en: { request: 'Requested model', upstream: 'Routed model', response: 'Response model' },
ja: { request: 'リクエストモデル', upstream: 'ルーティングモデル', response: '応答モデル' },
'zh-TW': { request: '請求模型', upstream: '路由模型', response: '響應模型' },
};
function parseCsv(text) {
@@ -70,7 +70,7 @@ function parseCsv(text) {
main: cell.querySelector(':scope > strong')?.textContent || '',
sublines: Array.from(cell.querySelectorAll(':scope > small')).map(item => item.textContent || ''),
response: cell.querySelector(':scope > .usage-response-model')?.textContent || '',
title: cell.getAttribute('title'),
title: cell.getAttribute('title')?.split('\n').slice(0, 3).join('\n') ?? null,
})));
const assertNoHorizontalPageOverflow = async (label) => {
const dimensions = await page.evaluate(() => ({
@@ -89,26 +89,35 @@ function parseCsv(text) {
await open('zh-CN');
assert.equal(await modelCells().count(), 9, 'Every fixture record renders a model cell');
const zhDetails = await modelDetails();
assert.match(await modelCells().first().getAttribute('title'), /response_model.*上游声明/);
assert.deepEqual(await page.locator('.usage-events-table th').evaluateAll(cells => cells.slice(0, 2).map(cell => cell.className.split(' ')[0])), ['usage-th-time', 'usage-th-provider']);
assert.equal(await page.locator('.usage-td-provider strong').first().textContent(), 'Codex');
assert.equal(await page.locator('.usage-access-type').first().textContent(), 'API');
assert.equal(await page.locator('.usage-access-type').nth(1).textContent(), 'OAuth');
assert.equal(await page.locator('.usage-access-type').nth(2).textContent(), '未记录接入方式');
assert.deepEqual(zhDetails[0], {
main: 'astra',
sublines: ['gpt-6-astra', '↳ 上游响应: gpt-5.6-luna'],
response: '↳ 上游响应: gpt-5.6-luna',
title: '请求模型: astra\n上游模型: gpt-6-astra\n上游响应: gpt-5.6-luna',
sublines: ['gpt-6-astra', '响应模型: gpt-5.6-luna'],
response: '响应模型: gpt-5.6-luna',
title: '请求模型: astra\n路由模型: gpt-6-astra\n响应模型: gpt-5.6-luna',
}, 'Differing alias, upstream model, and upstream response remain distinguishable');
assert.deepEqual(zhDetails[1], {
main: 'same-alias',
sublines: ['gpt-6-sol', '↳ 上游响应: gpt-6-sol'],
response: '↳ 上游响应: gpt-6-sol',
title: '请求模型: same-alias\n上游模型: gpt-6-sol\n上游响应: gpt-6-sol',
}, 'A response equal to the upstream model still renders');
assert.deepEqual(zhDetails[2].sublines, ['openai/gpt-4o-latest', '↳ 上游响应: gpt-4o-2024-08-06'], 'Snapshot and prefix model names preserve the existing upstream subline');
assert.equal(zhDetails[2].title, '请求模型: gpt4o\n上游模型: openai/gpt-4o-latest\n上游响应: gpt-4o-2024-08-06');
sublines: ['gpt-6-sol'],
response: '',
title: '请求模型: same-alias\n路由模型: gpt-6-sol',
}, 'A response equal to the routed model is not repeated');
assert.deepEqual(zhDetails[2].sublines, ['openai/gpt-4o-latest', '响应模型: gpt-4o-2024-08-06'], 'Snapshot and prefix model names preserve the existing upstream subline');
assert.equal(zhDetails[2].title, '请求模型: gpt4o\n路由模型: openai/gpt-4o-latest\n响应模型: gpt-4o-2024-08-06');
assert.deepEqual(zhDetails.slice(3, 6).map(item => item.sublines), [[], [], []], 'Missing, empty, and whitespace response_model values stay omitted');
assert.deepEqual(zhDetails.slice(3, 6).map(item => item.title), [null, null, null], 'Legacy rows keep the existing native-title behavior');
assert.equal(await page.locator('[class*="mismatch"], [class*="warning"]').count(), 0, 'Model strings do not produce an inequality warning');
assert.ok(zhDetails.slice(3, 6).every(item => !item.title.includes('响应模型')), 'Legacy rows never invent a response model');
assert.equal(await page.locator('.usage-model-mismatch').count(), 5, 'Only responses different from the routed model are flagged');
assert.equal(await page.locator('.usage-td-cost strong').first().textContent(), '$0.0042');
assert.equal(await page.locator('.usage-td-cost strong').nth(1).textContent(), '$0.00');
assert.equal(await page.locator('.usage-td-cost').nth(2).locator('small').textContent(), '未定价');
await assertNoHorizontalPageOverflow('desktop');
for (const child of await modelCells().first().locator(':scope > strong, :scope > small').all()) {
assert.equal(await child.getAttribute('title'), zhDetails[0].title, 'Hovering each line exposes the full three-model title');
assert.equal((await child.getAttribute('title')).split('\n').slice(0, 3).join('\n'), zhDetails[0].title, 'Hovering each line exposes the full three-model title');
}
const longLine = await modelCells().nth(6).locator('.usage-response-model').evaluate(element => ({
truncated: element.scrollWidth > element.clientWidth,
@@ -131,6 +140,9 @@ function parseCsv(text) {
const responseIndex = header.indexOf('response_model');
assert.equal(responseIndex, aliasIndex + 1, 'response_model is immediately after alias in CSV');
const csvById = new Map(csvRows.slice(1).map(row => [row[0], row]));
assert.equal(csvById.get('differing')[header.indexOf('estimated_cost_usd')], '0.0042');
assert.equal(csvById.get('matching')[header.indexOf('estimated_cost_usd')], '0');
assert.equal(csvById.get('missing')[header.indexOf('estimated_cost_usd')], '');
assert.equal(csvById.get('differing')[responseIndex], 'gpt-5.6-luna', 'CSV preserves a regular response model');
assert.equal(csvById.get('matching')[responseIndex], 'gpt-6-sol', 'CSV preserves a response equal to model');
assert.equal(csvById.get('missing')[responseIndex], '', 'CSV preserves an empty legacy response field');
@@ -142,13 +154,13 @@ function parseCsv(text) {
for (const [locale, labels] of Object.entries(locales)) {
await open(locale);
const details = await modelDetails();
assert.equal(details[0].response, `↳ ${labels.response}: gpt-5.6-luna`, `${locale}: response line is localized`);
assert.equal(details[0].response, `${labels.response}: gpt-5.6-luna`, `${locale}: response line is localized`);
assert.equal(details[0].title, `${labels.request}: astra\n${labels.upstream}: gpt-6-astra\n${labels.response}: gpt-5.6-luna`, `${locale}: model title localizes all three labels`);
}
for (const [theme, color] of [['light', 'rgb(194, 65, 12)'], ['dark', 'rgb(251, 146, 60)']]) {
for (const [theme, color] of [['light', 'rgb(154, 88, 8)'], ['dark', 'rgb(240, 188, 115)']]) {
await open('zh-CN', theme);
assert.equal(await modelCells().first().locator('.usage-response-model').evaluate(element => getComputedStyle(element).color), color, `${theme}: response line uses the theme's readable orange`);
assert.equal(await modelCells().first().locator('.usage-response-model').evaluate(element => getComputedStyle(element).color), color, `${theme}: response line uses the theme's mismatch text color`);
if (process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR, `response-model-${theme}.png`) });
+3 -3
View File
@@ -171,7 +171,7 @@ function parseCsv(text) {
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 0, 'The redundant request log heading is removed');
const firstRow = page.locator('.usage-events-table tbody tr').first();
assert.equal(await page.locator('.usage-events-table th').count(), 12, 'Saved settings cannot restore removed duplicate columns');
assert.equal(await page.locator('.usage-events-table th').count(), 13, 'Saved settings cannot restore removed duplicate columns');
assert.equal(await firstRow.locator('.usage-td-source svg, .usage-td-source img').count(), 0, 'Source is plain text without a logo');
assert.equal(await firstRow.locator('.usage-td-total').getAttribute('title'), '1,200 tokens', 'The displayed total uses the recorded total instead of adding cache and reasoning again');
assert.equal(await firstRow.locator('.tone-input').getAttribute('aria-label'), 'Input: 1000');
@@ -291,7 +291,7 @@ function parseCsv(text) {
await page.locator('.usage-col-settings-btn').click();
const checkboxes = page.locator('.usage-column-option input');
assert.equal(await checkboxes.count(), 12, 'Column settings contain only the remaining columns');
assert.equal(await checkboxes.count(), 13, 'Column settings contain only the remaining columns');
for (let index = 2; index < await checkboxes.count(); index += 1) await checkboxes.nth(index).uncheck();
await page.locator('.usage-column-dialog-actions .primary-button').click();
await page.waitForFunction(() => document.querySelector('.usage-table-top-scrollbar').classList.contains('is-hidden'));
@@ -345,7 +345,7 @@ function parseCsv(text) {
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).count(), 0, 'The request log has no reset filters control');
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('test-model');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 266');
assert.equal(await page.locator('.usage-events-table tbody strong[title="test-model"]').count(), 20, 'Changing a filter resets pagination and shows matching models');
assert.deepEqual(await page.locator('.usage-td-model > strong').allTextContents(), Array(20).fill('test-model'), 'Changing a filter resets pagination and shows matching models');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('test-source');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 133');
assert.ok((await page.locator('.usage-td-source').allTextContents()).every(text => text === 'Test source'));
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from 'bun:test';
import { usageModelDetails } from '../src/services/usageModel';
test('compares reported response with routed model, not the requested alias', () => {
expect(usageModelDetails('gpt-routed', 'my-alias', 'gpt-routed')).toMatchObject({ showResolved: true, mismatch: false, showResponseInTooltip: false });
expect(usageModelDetails('gpt-routed', 'my-alias', 'my-alias')).toMatchObject({ mismatch: true });
expect(usageModelDetails('gpt-routed', '', 'different')).toMatchObject({ showResolved: false, mismatch: true });
});
test('missing response and route degrade without inventing model names', () => {
expect(usageModelDetails('gpt-routed', '', ' ')).toMatchObject({ requested: 'gpt-routed', mismatch: false });
expect(usageModelDetails('', 'alias', 'reported')).toMatchObject({ mismatch: false, showResponseInTooltip: true });
expect(usageModelDetails(' gpt-routed ', ' gpt-routed ', ' gpt-routed ')).toMatchObject({ showResolved: false, mismatch: false });
});
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from 'bun:test';
import { usageProviderDetails } from '../src/services/usageProvider';
test('usage API authentication distinguishes API routes from OAuth', () => {
expect(usageProviderDetails('codex', 'apikey')).toMatchObject({ name: 'Codex', access: 'API' });
expect(usageProviderDetails('codex', 'oauth')).toMatchObject({ name: 'Codex', access: 'OAuth' });
expect(usageProviderDetails(' My gateway ', 'apikey')).toMatchObject({ name: 'My gateway', access: 'API' });
});
test('missing or new API values are not guessed from model or credentials', () => {
expect(usageProviderDetails('codex')).toMatchObject({ name: 'Codex', access: '' });
expect(usageProviderDetails('', 'unknown')).toMatchObject({ name: '—', access: '' });
expect(usageProviderDetails('custom', 'new-method')).toMatchObject({ name: 'custom', access: 'new-method' });
});