diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index cf848ff..9d538a6 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -258,6 +258,7 @@ pub(crate) struct UsageOverview { rpm: f64, tpm: f64, tps: f64, + tps_sample_count: u64, average_latency_ms: f64, cache_hit_rate: f64, estimated_cost: f64, @@ -2129,10 +2130,42 @@ fn load_usage_overview( COALESCE(SUM(cache_creation_tokens), 0), COALESCE(SUM(total_tokens), 0), COALESCE(SUM(latency_ms), 0), - COALESCE(AVG(CASE - WHEN output_tokens > 0 AND latency_ms > 0 - THEN CAST(output_tokens AS REAL) * 1000.0 / latency_ms - END), 0.0), + COALESCE( + SUM(CASE + WHEN generate != 0 + AND failed = 0 + AND canceled = 0 + AND output_tokens > 0 + AND ttft_ms IS NOT NULL + AND ttft_ms > 0 + AND latency_ms > ttft_ms + THEN output_tokens + ELSE 0 + END) * 1000.0 + / NULLIF(SUM(CASE + WHEN generate != 0 + AND failed = 0 + AND canceled = 0 + AND output_tokens > 0 + AND ttft_ms IS NOT NULL + AND ttft_ms > 0 + AND latency_ms > ttft_ms + THEN latency_ms - ttft_ms + ELSE 0 + END), 0), + 0.0 + ), + COALESCE(SUM(CASE + WHEN generate != 0 + AND failed = 0 + AND canceled = 0 + AND output_tokens > 0 + AND ttft_ms IS NOT NULL + AND ttft_ms > 0 + AND latency_ms > ttft_ms + THEN 1 + ELSE 0 + END), 0), MIN(timestamp_ms), MAX(timestamp_ms) FROM usage_events{} @@ -2157,8 +2190,9 @@ fn load_usage_overview( row.get::<_, i64>(9)?, row.get::<_, i64>(10)?, row.get::<_, f64>(11)?, - row.get::<_, Option>(12)?, + row.get::<_, i64>(12)?, row.get::<_, Option>(13)?, + row.get::<_, Option>(14)?, )) }, ) @@ -2226,11 +2260,12 @@ fn load_usage_overview( overview.average_latency_ms = from_sql_i64(summary.10) as f64 / overview.total_requests as f64; overview.tps = summary.11; + overview.tps_sample_count = from_sql_i64(summary.12); if overview.input_tokens > 0 { overview.cache_hit_rate = (overview.cache_read_tokens as f64 / overview.input_tokens as f64).min(1.0); } - let minutes = query_window_minutes(query, summary.12, summary.13); + let minutes = query_window_minutes(query, summary.13, summary.14); overview.rpm = overview.total_requests as f64 / minutes; overview.tpm = overview.total_tokens as f64 / minutes; } @@ -4174,7 +4209,8 @@ mod tests { assert_eq!(overview.total_requests, 1); assert_eq!(overview.failure_count, 1); - assert_eq!(overview.tps, 200.0); + assert_eq!(overview.tps, 0.0); + assert_eq!(overview.tps_sample_count, 0); assert_eq!(overview.cache_hit_rate, 0.2); assert!((overview.estimated_cost - 0.0003205).abs() < 0.0000001); assert_eq!(overview.priced_requests, 1); @@ -4185,6 +4221,65 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn overview_tps_uses_weighted_generation_time_and_ignores_invalid_records() { + let root = test_root("tps-overview"); + let mut connection = open_test_database(&root); + + let mut first = sample_record("tps-1", "2026-07-17T20:30:00+08:00", "gpt-a"); + first.latency_ms = 1_000; + first.ttft_ms = Some(200); + first.tokens.output_tokens = 80; + + let mut second = sample_record("tps-2", "2026-07-17T20:31:00+08:00", "gpt-a"); + second.latency_ms = 2_000; + second.ttft_ms = Some(1_000); + second.tokens.output_tokens = 20; + + let mut missing_ttft = sample_record("tps-3", "2026-07-17T20:32:00+08:00", "gpt-a"); + missing_ttft.latency_ms = 500; + missing_ttft.ttft_ms = None; + missing_ttft.tokens.output_tokens = 100; + + let mut equal_latency = sample_record("tps-4", "2026-07-17T20:33:00+08:00", "gpt-a"); + equal_latency.latency_ms = 500; + equal_latency.ttft_ms = Some(500); + equal_latency.tokens.output_tokens = 100; + + let mut failed = sample_record("tps-5", "2026-07-17T20:34:00+08:00", "gpt-a"); + failed.failed = true; + failed.tokens.output_tokens = 100; + + let mut canceled = sample_record("tps-6", "2026-07-17T20:35:00+08:00", "gpt-a"); + canceled.canceled = true; + canceled.tokens.output_tokens = 100; + + let mut non_generation = sample_record("tps-7", "2026-07-17T20:36:00+08:00", "gpt-a"); + non_generation.generate = false; + non_generation.tokens.output_tokens = 100; + + insert_usage_records( + &mut connection, + &[ + first, + second, + missing_ttft, + equal_latency, + failed, + canceled, + non_generation, + ], + ) + .unwrap(); + + let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap(); + + assert!((overview.tps - (100.0 * 1_000.0 / 1_800.0)).abs() < f64::EPSILON); + assert_eq!(overview.tps_sample_count, 2); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn estimates_gpt56_cost_with_cache_and_service_tier_rules() { let terra = official_model_price("openai/gpt-5.6-terra").unwrap(); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 1aa9526..3bb0bd3 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -150,8 +150,8 @@ export const jaOverrides = { 'usage.stat.successMetaTitle': '成功 {success} 件、失敗 {failed} 件、キャンセル {canceled} 件', 'usage.stat.averageLatency': '平均レイテンシ', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': 'RPM {rpm} · 平均レイテンシ {latency} ms', - 'usage.stat.performanceMetaTitle': 'TPS {tps}、RPM {rpm}、平均レイテンシ {latency} ms', + 'usage.stat.performanceMeta': '有効サンプル {samples} 件 · RPM {rpm}', + 'usage.stat.performanceMetaTitle': 'TPS {tps}、有効サンプル {samples} 件、RPM {rpm}、平均レイテンシ {latency} ms', 'usage.stat.cacheHitRate': 'キャッシュヒット率', 'usage.stat.cacheHitMeta': 'キャッシュ読取 {hit} · 入力 {input}', 'usage.stat.cacheHitMetaTitle': 'キャッシュヒット率 {rate}%、読取 {hit} Token、入力 {input} Token', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c07c5d9..0ba13ad 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -150,8 +150,8 @@ export const en: Record = { 'usage.stat.successMetaTitle': '{success} succeeded, {failed} failed, {canceled} canceled', 'usage.stat.averageLatency': 'Average Latency', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': 'RPM {rpm} · Avg. latency {latency} ms', - 'usage.stat.performanceMetaTitle': 'TPS {tps}, RPM {rpm}, average latency {latency} ms', + 'usage.stat.performanceMeta': '{samples} samples · RPM {rpm}', + 'usage.stat.performanceMetaTitle': 'TPS {tps}, {samples} valid samples, RPM {rpm}, average latency {latency} ms', 'usage.stat.cacheHitRate': 'Cache Hit Rate', 'usage.stat.cacheHitMeta': 'Cache read {hit} · Input {input}', 'usage.stat.cacheHitMetaTitle': 'Cache hit rate {rate}%, cache read {hit} tokens, input {input} tokens', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c0385d5..c8bae91 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -148,8 +148,8 @@ export const zhCN = { 'usage.stat.successMetaTitle': '成功 {success} 次,失败 {failed} 次,取消 {canceled} 次', 'usage.stat.averageLatency': '平均延迟', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': 'RPM {rpm} · 平均延迟 {latency} ms', - 'usage.stat.performanceMetaTitle': 'TPS {tps},RPM {rpm},平均延迟 {latency} ms', + 'usage.stat.performanceMeta': '{samples} 条有效样本 · RPM {rpm}', + 'usage.stat.performanceMetaTitle': 'TPS {tps},{samples} 条有效样本,RPM {rpm},平均延迟 {latency} ms', 'usage.stat.cacheHitRate': '缓存命中率', 'usage.stat.cacheHitMeta': '缓存读取 {hit} · 输入 {input}', 'usage.stat.cacheHitMetaTitle': '缓存命中率 {rate}%,缓存读取 {hit} Token,输入 {input} Token', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index d30e3e1..a6b8fa7 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -64,6 +64,7 @@ type UsageOverview = { rpm: number; tpm: number; tps: number; + tpsSampleCount: number; averageLatencyMs: number; cacheHitRate: number; estimatedCost: number; @@ -818,13 +819,15 @@ function OverviewView({ overview }: { overview: UsageOverview }) { }, { label: t('usage.stat.tps'), - value: `${overview.tps.toFixed(1)} TPS`, + value: overview.tpsSampleCount > 0 ? `${overview.tps.toFixed(1)} TPS` : '—', meta: t('usage.stat.performanceMeta', { + samples: compactNumber(overview.tpsSampleCount), rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs), }), metaTitle: t('usage.stat.performanceMetaTitle', { - tps: overview.tps.toFixed(1), + tps: overview.tpsSampleCount > 0 ? overview.tps.toFixed(1) : '—', + samples: compactNumber(overview.tpsSampleCount), rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs), }),