feat(usage): filter-driven Hero with cache-normalized totals

- Normalize OpenAI/Gemini input_tokens semantics in SQL via the new
  fresh_input_sql helper (cache_read subtracted at query time, no data
  migration). Recovers correct cache hit rates for Codex/Gemini.
- Add get_usage_summary_by_app endpoint for per-app split (single
  UNION ALL + GROUP BY, avoids N+1).
- Replace UsageSummaryCards + AppBreakdownRail with a single
  filter-driven UsageHero card; clicking a filter button now truly
  changes the displayed numbers and the title accent color.
- Tighten KNOWN_APP_TYPES to the 3 app_types whose token data is
  reliably collected (claude/codex/gemini); hide claude-desktop,
  hermes, opencode, openclaw filter buttons and i18n keys.
- Flag cache_creation as N/A for OpenAI-style protocols (Codex,
  Gemini); show a "partial" tooltip when the All view mixes both
  protocol families.
This commit is contained in:
Jason
2026-05-13 10:27:29 +08:00
parent c12364a940
commit edf28b6422
17 changed files with 931 additions and 219 deletions
+12 -4
View File
@@ -6,6 +6,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useRequestDetail } from "@/lib/query/usage";
import { getFreshInputTokens } from "@/types/usage";
interface RequestDetailPanelProps {
requestId: string;
@@ -50,6 +51,9 @@ export function RequestDetailPanel({
);
}
const freshInput = getFreshInputTokens(request);
const isCacheInclusive = request.inputTokens !== freshInput;
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
@@ -135,7 +139,13 @@ export function RequestDetailPanel({
{t("usage.inputTokens", "输入 Tokens")}
</dt>
<dd className="font-mono">
{request.inputTokens.toLocaleString()}
{freshInput.toLocaleString()}
{isCacheInclusive && (
<span className="ml-2 text-xs text-muted-foreground/70 font-normal">
({t("usage.rawInputLabel", "原始")}:{" "}
{request.inputTokens.toLocaleString()})
</span>
)}
</dd>
</div>
<div>
@@ -167,9 +177,7 @@ export function RequestDetailPanel({
{t("usage.totalTokens", "总计")}
</dt>
<dd className="text-lg font-semibold">
{(
request.inputTokens + request.outputTokens
).toLocaleString()}
{(freshInput + request.outputTokens).toLocaleString()}
</dd>
</div>
</dl>
+28 -7
View File
@@ -18,7 +18,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useRequestLogs } from "@/lib/query/usage";
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
import {
KNOWN_APP_TYPES,
getFreshInputTokens,
type LogFilters,
type UsageRangeSelection,
} from "@/types/usage";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import {
@@ -138,9 +143,11 @@ export function RequestLogTable({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
{KNOWN_APP_TYPES.map((at) => (
<SelectItem key={at} value={at}>
{t(`usage.appFilter.${at}`, { defaultValue: at })}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -323,9 +330,23 @@ export function RequestLogTable({
</div>
</TableCell>
<TableCell className="text-center px-1.5">
<div className="tabular-nums">
{fmtInt(log.inputTokens, locale)}
</div>
{(() => {
const freshInput = getFreshInputTokens(log);
const isCacheInclusive =
log.inputTokens !== freshInput;
return (
<div
className="tabular-nums"
title={
isCacheInclusive
? `Raw: ${log.inputTokens.toLocaleString()}`
: undefined
}
>
{fmtInt(freshInput, locale)}
</div>
);
})()}
{(log.cacheReadTokens > 0 ||
log.cacheCreationTokens > 0) && (
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
+9 -10
View File
@@ -1,11 +1,15 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { UsageSummaryCards } from "./UsageSummaryCards";
import { UsageHero } from "./UsageHero";
import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { AppTypeFilter, UsageRangeSelection } from "@/types/usage";
import {
KNOWN_APP_TYPES,
type AppTypeFilter,
type UsageRangeSelection,
} from "@/types/usage";
import { motion } from "framer-motion";
import {
BarChart3,
@@ -30,12 +34,7 @@ import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
"all",
"claude",
"codex",
"gemini",
];
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
export function UsageDashboard() {
const { t, i18n } = useTranslation();
@@ -127,9 +126,9 @@ export function UsageDashboard() {
</div>
</div>
<UsageSummaryCards
<UsageHero
range={range}
appType={appType}
appType={appType === "all" ? undefined : appType}
refreshIntervalMs={refreshIntervalMs}
/>
+357
View File
@@ -0,0 +1,357 @@
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import { Card, CardContent } from "@/components/ui/card";
import { useUsageSummaryByApp } from "@/lib/query/usage";
import { cn } from "@/lib/utils";
import {
Activity,
ArrowDownToLine,
ArrowUpFromLine,
Database,
Info,
Loader2,
Sparkles,
Zap,
} from "lucide-react";
import {
fmtUsd,
formatTokensShort,
getResolvedLang,
parseFiniteNumber,
} from "./format";
import {
CACHE_INCLUSIVE_APP_TYPES,
type AppType,
type UsageRangeSelection,
type UsageSummary,
type UsageSummaryByApp,
} from "@/types/usage";
interface UsageHeroProps {
range: UsageRangeSelection;
appType?: string;
refreshIntervalMs: number;
}
interface TitleTheme {
/** Foreground color for the icon glyph (text-* class). */
accent: string;
/** Background tint for the icon square (bg-* class). */
iconBg: string;
}
const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
all: { accent: "text-primary", iconBg: "bg-primary/10" },
claude: {
accent: "text-amber-600 dark:text-amber-400",
iconBg: "bg-amber-500/10",
},
codex: {
accent: "text-emerald-600 dark:text-emerald-400",
iconBg: "bg-emerald-500/10",
},
gemini: {
accent: "text-sky-600 dark:text-sky-400",
iconBg: "bg-sky-500/10",
},
};
/**
* Combine per-app summaries into a single rolled-up summary.
*
* The backend's per-app rows already use fresh-input semantics (cache-inclusive
* providers have been normalized in SQL), so plain addition is correct here.
* `cacheHitRate` and `successRate` must be re-derived from the summed counts
* rather than averaged across rows.
*/
function aggregateSummaries(items: UsageSummary[]): UsageSummary {
let totalRequests = 0;
let successCount = 0;
let totalCostNum = 0;
let input = 0;
let output = 0;
let cacheCreation = 0;
let cacheRead = 0;
for (const s of items) {
totalRequests += s.totalRequests;
successCount += Math.round((s.totalRequests * s.successRate) / 100);
totalCostNum += parseFiniteNumber(s.totalCost) ?? 0;
input += s.totalInputTokens;
output += s.totalOutputTokens;
cacheCreation += s.totalCacheCreationTokens;
cacheRead += s.totalCacheReadTokens;
}
const cacheableInput = input + cacheCreation + cacheRead;
return {
totalRequests,
totalCost: totalCostNum.toFixed(6),
totalInputTokens: input,
totalOutputTokens: output,
totalCacheCreationTokens: cacheCreation,
totalCacheReadTokens: cacheRead,
successRate: totalRequests > 0 ? (successCount / totalRequests) * 100 : 0,
realTotalTokens: input + output + cacheCreation + cacheRead,
cacheHitRate: cacheableInput > 0 ? cacheRead / cacheableInput : 0,
};
}
function pickSummary(
apps: UsageSummaryByApp[],
appType: string | undefined,
): UsageSummary | undefined {
if (apps.length === 0) return undefined;
if (appType) {
return apps.find((a) => a.appType === appType)?.summary;
}
return aggregateSummaries(apps.map((a) => a.summary));
}
type CacheWriteState = "ok" | "partial" | "na";
/**
* Anthropic-style protocols report cache creation; OpenAI-style protocols
* (Codex/Gemini) do not — so a mix shows the number with a caveat, all-OpenAI
* shows N/A. `appTypes` is the set actually contributing to the displayed
* summary (a single app, or every app that participated in "all").
*/
function deriveCacheWriteState(appTypes: string[]): CacheWriteState {
if (appTypes.length === 0) return "ok";
const inclusive = appTypes.filter((t) =>
CACHE_INCLUSIVE_APP_TYPES.has(t),
).length;
if (inclusive === appTypes.length) return "na";
if (inclusive === 0) return "ok";
return "partial";
}
export function UsageHero({
range,
appType,
refreshIntervalMs,
}: UsageHeroProps) {
const { t, i18n } = useTranslation();
const lang = getResolvedLang(i18n);
const { data, isLoading } = useUsageSummaryByApp(range, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
// No client-side filtering: Hero's totals must match the Trend/Logs/Stats
// below, which all go through the backend's full set of app_types. The
// KNOWN_APP_TYPES list only governs which filter buttons appear, not which
// rows participate in the "all" aggregate.
const allApps = data ?? [];
const summary = pickSummary(allApps, appType);
const titleTheme =
TITLE_THEMES[(appType ?? "all") as keyof typeof TITLE_THEMES] ??
TITLE_THEMES.all;
const appLabel =
appType && appType in TITLE_THEMES ? t(`usage.appFilter.${appType}`) : null;
const cacheWriteState = deriveCacheWriteState(
appType ? [appType] : allApps.map((a) => a.appType),
);
const input = summary?.totalInputTokens ?? 0;
const output = summary?.totalOutputTokens ?? 0;
const cacheWrite = summary?.totalCacheCreationTokens ?? 0;
const cacheRead = summary?.totalCacheReadTokens ?? 0;
const realTotal = summary?.realTotalTokens ?? 0;
const hitRate = summary?.cacheHitRate ?? 0;
const totalCost = parseFiniteNumber(summary?.totalCost);
const requests = summary?.totalRequests ?? 0;
const cacheWriteDisplay = {
value:
cacheWriteState === "na" ? "N/A" : formatTokensShort(cacheWrite, lang),
muted: cacheWriteState === "na",
tooltip:
cacheWriteState === "na"
? t(
"usage.cacheWriteNotReported",
"OpenAI 协议不区分缓存写入,仅上报缓存命中",
)
: cacheWriteState === "partial"
? t(
"usage.cacheWritePartial",
"部分协议(如 OpenAI)不上报缓存写入,数值可能偏低",
)
: undefined,
};
if (isLoading) {
return (
<Card className="border border-border/50 bg-card/40 backdrop-blur-sm">
<CardContent className="flex items-center justify-center min-h-[200px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
</CardContent>
</Card>
);
}
const hitPercent = Math.max(0, Math.min(100, hitRate * 100));
const hitPercentLabel = hitPercent.toFixed(hitPercent >= 99.95 ? 0 : 1);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card className="relative overflow-hidden border border-border/50 bg-gradient-to-br from-primary/5 via-card/50 to-background/50 backdrop-blur-xl shadow-sm">
<CardContent className="p-6 md:p-8">
{/* Header: title + cost */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-4">
<div className="flex items-center gap-2">
<div className={cn("p-2 rounded-lg", titleTheme.iconBg)}>
<Zap className={cn("h-4 w-4", titleTheme.accent)} />
</div>
<span className="text-sm font-medium text-muted-foreground">
{appLabel && (
<>
<span className={cn("font-semibold", titleTheme.accent)}>
{appLabel}
</span>
<span className="mx-1.5 text-muted-foreground/40">·</span>
</>
)}
{t("usage.realTotal", "真实消耗 Tokens")}
</span>
</div>
<div className="flex items-center gap-4 text-right">
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
{t("usage.totalRequests")}
</span>
<span className="text-sm font-semibold flex items-center gap-1 justify-end">
<Activity className="h-3.5 w-3.5 text-blue-500" />
{requests.toLocaleString()}
</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
{t("usage.totalCost")}
</span>
<span className="text-sm font-semibold text-green-500">
{totalCost == null ? "--" : fmtUsd(totalCost, 4)}
</span>
</div>
</div>
</div>
{/* Hero number */}
<div className="flex flex-col items-start mb-6">
<div
className="text-4xl md:text-5xl font-bold tracking-tight tabular-nums leading-tight"
title={realTotal.toLocaleString()}
>
{realTotal.toLocaleString()}
</div>
<div className="text-sm text-muted-foreground mt-1">
{formatTokensShort(realTotal, lang, 2)}{" "}
{t("usage.tokensSuffix", "tokens")}
</div>
</div>
{/* Breakdown row: 4 mini stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
<MiniStat
icon={<ArrowDownToLine className="h-3.5 w-3.5" />}
label={t("usage.freshInput", "新增输入")}
value={formatTokensShort(input, lang)}
accent="text-blue-500"
/>
<MiniStat
icon={<ArrowUpFromLine className="h-3.5 w-3.5" />}
label={t("usage.output")}
value={formatTokensShort(output, lang)}
accent="text-purple-500"
/>
<MiniStat
icon={<Database className="h-3.5 w-3.5" />}
label={t("usage.cacheWrite", "缓存写入")}
value={cacheWriteDisplay.value}
accent="text-amber-500"
muted={cacheWriteDisplay.muted}
tooltip={cacheWriteDisplay.tooltip}
/>
<MiniStat
icon={<Sparkles className="h-3.5 w-3.5" />}
label={t("usage.cacheRead", "缓存命中")}
value={formatTokensShort(cacheRead, lang)}
accent="text-emerald-500"
/>
</div>
{/* Hit rate progress */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{t("usage.cacheHitRate", "缓存命中率")}
</span>
<span className="font-semibold text-emerald-500 tabular-nums">
{hitPercentLabel}%
</span>
</div>
<div className="relative h-2 rounded-full bg-muted/50 overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-emerald-500/80 to-emerald-400 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${hitPercent}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
}
interface MiniStatProps {
icon: React.ReactNode;
label: string;
value: string;
accent: string;
/** Optional hover tooltip — used to flag protocol-level caveats. */
tooltip?: string;
/** Visually de-emphasize the value (e.g. for "N/A" cases). */
muted?: boolean;
}
function MiniStat({
icon,
label,
value,
accent,
tooltip,
muted,
}: MiniStatProps) {
return (
<div
className="flex flex-col gap-1 rounded-lg border border-border/40 bg-background/40 px-3 py-2.5"
title={tooltip}
>
<div
className={`flex items-center gap-1.5 text-xs text-muted-foreground ${accent}`}
>
{icon}
<span className="text-foreground/70">{label}</span>
{tooltip && (
<Info className="h-3 w-3 text-muted-foreground/60 shrink-0" />
)}
</div>
<div
className={cn(
"text-base font-semibold tabular-nums",
muted && "text-muted-foreground/70",
)}
>
{value}
</div>
</div>
);
}
-173
View File
@@ -1,173 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { useUsageSummary } from "@/lib/query/usage";
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
import { motion } from "framer-motion";
import { fmtUsd, parseFiniteNumber } from "./format";
import type { UsageRangeSelection } from "@/types/usage";
interface UsageSummaryCardsProps {
range: UsageRangeSelection;
appType?: string;
refreshIntervalMs: number;
}
export function UsageSummaryCards({
range,
appType,
refreshIntervalMs,
}: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFiniteNumber(summary?.totalCost);
const inputTokens = summary?.totalInputTokens ?? 0;
const outputTokens = summary?.totalOutputTokens ?? 0;
const totalTokens = inputTokens + outputTokens;
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
return [
{
title: t("usage.totalRequests"),
value: totalRequests.toLocaleString(),
icon: Activity,
color: "text-blue-500",
bg: "bg-blue-500/10",
subValue: null,
},
{
title: t("usage.totalCost"),
value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
icon: DollarSign,
color: "text-green-500",
bg: "bg-green-500/10",
subValue: null,
},
{
title: t("usage.totalTokens"),
value: totalTokens.toLocaleString(),
icon: Layers,
color: "text-purple-500",
bg: "bg-purple-500/10",
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>{t("usage.input")}</span>
<span className="text-foreground/80">
{(inputTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>{t("usage.output")}</span>
<span className="text-foreground/80">
{(outputTokens / 1000).toFixed(1)}k
</span>
</div>
</div>
),
},
{
title: t("usage.cacheTokens"),
value: totalCacheTokens.toLocaleString(),
icon: Database,
color: "text-orange-500",
bg: "bg-orange-500/10",
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>{t("usage.cacheWrite")}</span>
<span className="text-foreground/80">
{(cacheWriteTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>{t("usage.cacheRead")}</span>
<span className="text-foreground/80">
{(cacheReadTokens / 1000).toFixed(1)}k
</span>
</div>
</div>
),
},
];
}, [summary, t]);
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card
key={i}
className="border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm"
>
<CardContent className="p-6 flex items-center justify-center min-h-[160px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
</CardContent>
</Card>
))}
</div>
);
}
return (
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-4 md:grid-cols-4"
>
{stats.map((stat, i) => (
<motion.div key={i} variants={item}>
<Card className="relative h-full overflow-hidden border border-border/50 bg-gradient-to-br from-card/50 to-background/50 backdrop-blur-xl hover:from-card/60 hover:to-background/60 transition-all shadow-sm">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-2">
<p className="text-sm font-medium text-muted-foreground">
{stat.title}
</p>
<div className={`p-2 rounded-lg ${stat.bg}`}>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</div>
</div>
<div className="space-y-1">
<h3 className="text-2xl font-bold truncate" title={stat.value}>
{stat.value}
</h3>
</div>
{stat.subValue || (
/* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */
<div className="mt-3 pt-3 border-t border-transparent h-[52px]"></div>
)}
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
);
}
+34
View File
@@ -37,3 +37,37 @@ export function getLocaleFromLanguage(language: string): string {
if (language.startsWith("ja")) return "ja-JP";
return "en-US";
}
interface I18nLike {
resolvedLanguage?: string;
language?: string;
}
export function getResolvedLang(i18n: I18nLike): string {
return i18n.resolvedLanguage || i18n.language || "en";
}
/**
* Token 数量的紧凑显示。
*
* Why: 中日文用户期待 "亿/万" 量纲;英文用户期待 K/M/B。共用同一份格式化
* 逻辑避免 Hero 卡和分应用卡显示不一致。`compactDecimals=2` 用于 Hero
* 大数副标(更精确),默认 1 位用于卡片副字段。
*/
export function formatTokensShort(
value: number,
lang: string,
compactDecimals: 1 | 2 = 1,
): string {
if (!Number.isFinite(value) || value <= 0) return "0";
const decimals = compactDecimals;
if (lang.startsWith("zh") || lang.startsWith("ja")) {
if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`;
if (value >= 1e4) return `${(value / 1e4).toFixed(decimals)}`;
return value.toLocaleString();
}
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
if (value >= 1e3) return `${(value / 1e3).toFixed(decimals)}K`;
return value.toLocaleString();
}
+9 -1
View File
@@ -1135,8 +1135,13 @@
"rangeToday": "Last 24 hours (hourly)",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"totalTokens": "Total Tokens",
"totalTokens": "New Tokens (Input+Output)",
"cacheTokens": "Cache Tokens",
"realTotal": "Tokens Processed",
"realTotalHint": "Input + Output + Cache Write + Cache Read",
"tokensSuffix": "tokens",
"freshInput": "Fresh Input",
"cacheHitRate": "Cache Hit Rate",
"requestLogs": "Request Logs",
"providerStats": "Provider Stats",
"modelStats": "Model Stats",
@@ -1165,6 +1170,7 @@
"codex": "Codex",
"gemini": "Gemini"
},
"rawInputLabel": "Raw",
"dataSources": "Data Sources",
"dataSource": {
"proxy": "Routing",
@@ -1225,6 +1231,8 @@
"input": "Input",
"output": "Output",
"cacheWrite": "Creation",
"cacheWriteNotReported": "OpenAI protocol doesn't distinguish cache creation; only cache hits are reported",
"cacheWritePartial": "Some protocols (e.g. OpenAI) don't report cache creation; the value may be incomplete",
"cacheRead": "Hit",
"baseCost": "Base",
"costMultiplier": "Cost Multiplier",
+9 -1
View File
@@ -1135,8 +1135,13 @@
"rangeToday": "直近24時間 (時間別)",
"rangeLast7Days": "過去7日間",
"rangeLast30Days": "過去30日間",
"totalTokens": "トークン",
"totalTokens": "新規トークン (Input+Output)",
"cacheTokens": "キャッシュトークン",
"realTotal": "実消費トークン",
"realTotalHint": "Input + Output + キャッシュ書込 + キャッシュ命中",
"tokensSuffix": "tokens",
"freshInput": "新規入力",
"cacheHitRate": "キャッシュヒット率",
"requestLogs": "リクエストログ",
"providerStats": "プロバイダー統計",
"modelStats": "モデル統計",
@@ -1165,6 +1170,7 @@
"codex": "Codex",
"gemini": "Gemini"
},
"rawInputLabel": "原始",
"dataSources": "データソース",
"dataSource": {
"proxy": "ルーティング",
@@ -1225,6 +1231,8 @@
"input": "Input",
"output": "Output",
"cacheWrite": "作成",
"cacheWriteNotReported": "OpenAI プロトコルはキャッシュ作成を区別せず、キャッシュヒットのみ報告します",
"cacheWritePartial": "一部のプロトコル(OpenAIなど)はキャッシュ作成を報告しないため、値は不完全な可能性があります",
"cacheRead": "ヒット",
"baseCost": "基本",
"costMultiplier": "コスト倍率",
+9 -1
View File
@@ -1135,8 +1135,13 @@
"rangeToday": "过去 24 小时 (按小时)",
"rangeLast7Days": "过去 7 天",
"rangeLast30Days": "过去 30 天",
"totalTokens": " Token ",
"totalTokens": "新生成 Token (Input+Output)",
"cacheTokens": "缓存 Token",
"realTotal": "真实消耗 Tokens",
"realTotalHint": "Input + Output + 缓存写入 + 缓存命中",
"tokensSuffix": "tokens",
"freshInput": "新增输入",
"cacheHitRate": "缓存命中率",
"requestLogs": "请求日志",
"providerStats": "Provider 统计",
"modelStats": "模型统计",
@@ -1165,6 +1170,7 @@
"codex": "Codex",
"gemini": "Gemini"
},
"rawInputLabel": "原始",
"dataSources": "数据来源",
"dataSource": {
"proxy": "路由",
@@ -1225,6 +1231,8 @@
"input": "Input",
"output": "Output",
"cacheWrite": "创建",
"cacheWriteNotReported": "OpenAI 协议不区分缓存写入,仅上报缓存命中",
"cacheWritePartial": "部分协议(如 OpenAI)不上报缓存写入,数值可能偏低",
"cacheRead": "命中",
"baseCost": "基础",
"costMultiplier": "成本倍率",
+8
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
UsageSummary,
UsageSummaryByApp,
DailyStats,
ProviderStats,
ModelStats,
@@ -55,6 +56,13 @@ export const usageApi = {
return invoke("get_usage_summary", { startDate, endDate, appType });
},
getUsageSummaryByApp: async (
startDate?: number,
endDate?: number,
): Promise<UsageSummaryByApp[]> => {
return invoke("get_usage_summary_by_app", { startDate, endDate });
},
getUsageTrends: async (
startDate?: number,
endDate?: number,
+31
View File
@@ -45,6 +45,18 @@ export const usageKeys = {
customEndDate ?? 0,
appType ?? "all",
] as const,
summaryByApp: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
) =>
[
...usageKeys.all,
"summary-by-app",
preset,
customStartDate ?? 0,
customEndDate ?? 0,
] as const,
trends: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
@@ -133,6 +145,25 @@ export function useUsageSummary(
});
}
export function useUsageSummaryByApp(
range: UsageRangeSelection,
options?: UsageQueryOptions,
) {
return useQuery({
queryKey: usageKeys.summaryByApp(
range.preset,
range.customStartDate,
range.customEndDate,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageSummaryByApp(startDate, endDate);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useUsageTrends(
range: UsageRangeSelection,
appType?: string,
+65 -1
View File
@@ -71,6 +71,15 @@ export interface UsageSummary {
totalCacheCreationTokens: number;
totalCacheReadTokens: number;
successRate: number;
/** input + output + cache_creation + cache_read, all cache-normalized */
realTotalTokens: number;
/** cache_read / (input + cache_creation + cache_read), range 01 */
cacheHitRate: number;
}
export interface UsageSummaryByApp {
appType: string;
summary: UsageSummary;
}
export interface DailyStats {
@@ -129,7 +138,62 @@ export interface UsageRangeSelection {
customEndDate?: number;
}
export type AppTypeFilter = "all" | "claude" | "codex" | "gemini";
/**
* App types whose token usage is reliably collected by the proxy.
*
* The proxy has handlers for `claude-desktop` too, but in practice those
* requests overwhelmingly fail (500/503) and contribute near-zero tokens, so
* it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have
* no proxy handler at all — they appear only as managed apps elsewhere.
*/
export type AppType = "claude" | "codex" | "gemini";
export type AppTypeFilter = "all" | AppType;
export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
"claude",
"codex",
"gemini",
];
/**
* App types whose proxy uses an OpenAI-style protocol. Two consequences:
*
* 1. `inputTokens` already includes the cached portion (must subtract
* `cacheReadTokens` to get fresh-input semantics — see
* [getFreshInputTokens]).
* 2. The protocol does not report cache _creation_ separately, only cache
* _reads_. So `cacheCreationTokens` is always 0 for these app types and
* the UI should label it as N/A rather than 0.
*
* Mirror of the Rust `CACHE_INCLUSIVE_APP_TYPES` whitelist.
*/
export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet<string> = new Set([
"codex",
"gemini",
]);
/** Subset of request-log fields needed to derive cache-normalized input. */
export interface CacheNormalizableLog {
appType: string;
inputTokens: number;
cacheReadTokens: number;
}
/**
* For a single request log, return the input token count with cache reads
* removed. Anthropic-style providers already report `inputTokens` without
* cache, so they pass through unchanged.
*/
export function getFreshInputTokens(log: CacheNormalizableLog): number {
if (
CACHE_INCLUSIVE_APP_TYPES.has(log.appType) &&
log.inputTokens >= log.cacheReadTokens
) {
return log.inputTokens - log.cacheReadTokens;
}
return log.inputTokens;
}
export interface StatsFilters {
timeRange: UsageRangePreset;