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:
1 parent
0e7437493d
commit
dfba9b019f
20 files changed
+652
-152
No files matched your search
@@ -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"])
|
||||
|
||||
@@ -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), ®istered)
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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': '恢复自动显示',
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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`) });
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
Reference in new issue
Block a user