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();