mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
fix(usage): 修正 input_tokens 统计与显示
- SQL 查询不再用 settlement snapshot 的 billing_input_tokens 覆盖原始 input_tokens - usage_mapper 针对 OpenAI 格式在 input_tokens 缺失时从 total_tokens - output_tokens 推导 - 前端轮询合并优先采用最新 record 的 input_tokens / effective_input_tokens
This commit is contained in:
@@ -20,7 +20,7 @@ SELECT
|
||||
"usage".provider_endpoint_kind,
|
||||
COALESCE("usage".has_format_conversion, FALSE) AS has_format_conversion,
|
||||
COALESCE("usage".is_stream, FALSE) AS is_stream,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_input_tokens, "usage".input_tokens) AS INTEGER) AS input_tokens,
|
||||
CAST("usage".input_tokens AS INTEGER) AS input_tokens,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_output_tokens, "usage".output_tokens) AS INTEGER) AS output_tokens,
|
||||
CAST(COALESCE(
|
||||
CASE
|
||||
|
||||
@@ -20,7 +20,7 @@ SELECT
|
||||
"usage".provider_endpoint_kind,
|
||||
COALESCE("usage".has_format_conversion, FALSE) AS has_format_conversion,
|
||||
COALESCE("usage".is_stream, FALSE) AS is_stream,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_input_tokens, "usage".input_tokens) AS INTEGER) AS input_tokens,
|
||||
CAST("usage".input_tokens AS INTEGER) AS input_tokens,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_output_tokens, "usage".output_tokens) AS INTEGER) AS output_tokens,
|
||||
CAST(COALESCE(
|
||||
CASE
|
||||
|
||||
@@ -20,7 +20,7 @@ SELECT
|
||||
"usage".provider_endpoint_kind,
|
||||
COALESCE("usage".has_format_conversion, FALSE) AS has_format_conversion,
|
||||
COALESCE("usage".is_stream, FALSE) AS is_stream,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_input_tokens, "usage".input_tokens) AS INTEGER) AS input_tokens,
|
||||
CAST("usage".input_tokens AS INTEGER) AS input_tokens,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_output_tokens, "usage".output_tokens) AS INTEGER) AS output_tokens,
|
||||
CAST(COALESCE(
|
||||
CASE
|
||||
|
||||
@@ -20,7 +20,7 @@ SELECT
|
||||
"usage".provider_endpoint_kind,
|
||||
COALESCE("usage".has_format_conversion, FALSE) AS has_format_conversion,
|
||||
COALESCE("usage".is_stream, FALSE) AS is_stream,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_input_tokens, "usage".input_tokens) AS INTEGER) AS input_tokens,
|
||||
CAST("usage".input_tokens AS INTEGER) AS input_tokens,
|
||||
CAST(COALESCE(usage_settlement_snapshots.billing_output_tokens, "usage".output_tokens) AS INTEGER) AS output_tokens,
|
||||
CAST(COALESCE(
|
||||
CASE
|
||||
|
||||
@@ -452,8 +452,9 @@ fn usage_sql_reads_settlement_snapshots_for_single_record_fetches() {
|
||||
assert!(super::FIND_BY_REQUEST_ID_SQL.contains("settlement_billing_snapshot_schema_version"));
|
||||
assert!(super::FIND_BY_ID_SQL.contains("settlement_price_per_request"));
|
||||
for sql in [super::FIND_BY_REQUEST_ID_SQL, super::FIND_BY_ID_SQL] {
|
||||
assert!(sql.contains("CAST(\"usage\".input_tokens AS INTEGER) AS input_tokens"));
|
||||
assert!(sql.contains(
|
||||
"COALESCE(usage_settlement_snapshots.billing_input_tokens, \"usage\".input_tokens)"
|
||||
"usage_settlement_snapshots.billing_input_tokens AS settlement_billing_input_tokens"
|
||||
));
|
||||
assert!(sql.contains("usage_settlement_snapshots.billing_cache_creation_5m_tokens"));
|
||||
assert!(sql.contains(
|
||||
@@ -523,8 +524,9 @@ fn usage_sql_uses_json_null_placeholders_for_usage_payload_columns() {
|
||||
super::LIST_USAGE_AUDITS_PREFIX,
|
||||
super::LIST_RECENT_USAGE_AUDITS_PREFIX,
|
||||
] {
|
||||
assert!(sql.contains("CAST(\"usage\".input_tokens AS INTEGER) AS input_tokens"));
|
||||
assert!(sql.contains(
|
||||
"COALESCE(usage_settlement_snapshots.billing_input_tokens, \"usage\".input_tokens)"
|
||||
"usage_settlement_snapshots.billing_input_tokens AS settlement_billing_input_tokens"
|
||||
));
|
||||
assert!(sql.contains("usage_settlement_snapshots.billing_cache_creation_1h_tokens"));
|
||||
assert!(sql.contains(
|
||||
|
||||
@@ -26,6 +26,7 @@ impl UsageMapper {
|
||||
}
|
||||
}
|
||||
|
||||
derive_missing_input_tokens(raw_usage, api_format, &mut usage);
|
||||
usage.normalize_cache_creation_breakdown()
|
||||
}
|
||||
|
||||
@@ -166,6 +167,36 @@ fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
|
||||
mapping
|
||||
}
|
||||
|
||||
fn derive_missing_input_tokens(
|
||||
raw_usage: &serde_json::Value,
|
||||
api_format: &str,
|
||||
usage: &mut StandardizedUsage,
|
||||
) {
|
||||
if usage.input_tokens > 0 || api_family(api_format).as_str() != "openai" {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(total_tokens) = numeric_i64(raw_usage.get("total_tokens")) else {
|
||||
return;
|
||||
};
|
||||
let output_tokens = usage
|
||||
.output_tokens
|
||||
.max(numeric_i64(raw_usage.get("completion_tokens")).unwrap_or_default())
|
||||
.max(numeric_i64(raw_usage.get("output_tokens")).unwrap_or_default());
|
||||
let inferred_input_tokens = total_tokens.saturating_sub(output_tokens);
|
||||
if inferred_input_tokens > 0 {
|
||||
usage.input_tokens = inferred_input_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
fn numeric_i64(value: Option<&serde_json::Value>) -> Option<i64> {
|
||||
value.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
||||
})
|
||||
}
|
||||
|
||||
fn get_nested_value<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
|
||||
let mut current = value;
|
||||
for segment in path.split('.') {
|
||||
@@ -268,6 +299,44 @@ mod tests {
|
||||
assert_eq!(usage.reasoning_tokens, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_openai_responses_usage_with_missing_input_from_total() {
|
||||
let usage = map_usage_from_response(
|
||||
&serde_json::json!({
|
||||
"usage": {
|
||||
"output_tokens": 899,
|
||||
"total_tokens": 53_499,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 52_600
|
||||
}
|
||||
}
|
||||
}),
|
||||
"openai:cli",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 52_600);
|
||||
assert_eq!(usage.output_tokens, 899);
|
||||
assert_eq!(usage.cache_read_tokens, 52_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_missing_input_derivation_scoped_to_openai() {
|
||||
let usage = map_usage_from_response(
|
||||
&serde_json::json!({
|
||||
"usage": {
|
||||
"output_tokens": 7,
|
||||
"total_tokens": 17,
|
||||
"cache_read_input_tokens": 10
|
||||
}
|
||||
}),
|
||||
"claude:chat",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 0);
|
||||
assert_eq!(usage.output_tokens, 7);
|
||||
assert_eq!(usage.cache_read_tokens, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_openai_responses_with_top_level_cache_fields() {
|
||||
let usage = map_usage_from_response(
|
||||
|
||||
@@ -425,8 +425,10 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
// 保留本地的状态和所有通过轮询更新的字段
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
input_tokens: existing.input_tokens || record.input_tokens,
|
||||
effective_input_tokens: existing.effective_input_tokens ?? record.effective_input_tokens,
|
||||
input_tokens: Number.isFinite(record.input_tokens)
|
||||
? record.input_tokens
|
||||
: existing.input_tokens,
|
||||
effective_input_tokens: record.effective_input_tokens ?? existing.effective_input_tokens,
|
||||
output_tokens: existing.output_tokens || record.output_tokens,
|
||||
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens:
|
||||
|
||||
Reference in New Issue
Block a user