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

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