Compare commits

...

30 Commits

52 changed files with 2011 additions and 1375 deletions
@@ -82,3 +82,51 @@
gap: $spacing-lg;
}
.contentWithFloatingAction {
padding-bottom: calc(
var(--secondary-shell-floating-action-height, 56px) + 12px + env(safe-area-inset-bottom)
);
}
.floatingActionContainer {
position: fixed;
left: var(--content-center-x, 50%);
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
pointer-events: none;
width: fit-content;
max-width: calc(100vw - 24px);
}
.floatingActionSurface {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
:global([data-theme='dark']) {
.floatingActionSurface {
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
}
@media (max-width: 1200px) {
.floatingActionContainer {
max-width: calc(100vw - 16px);
}
.floatingActionSurface {
padding: 8px 10px;
}
}
+91 -34
View File
@@ -1,7 +1,9 @@
import { forwardRef, type ReactNode } from 'react';
import { forwardRef, useLayoutEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronLeft } from '@/components/ui/icons';
import { usePageTransitionLayer } from './PageTransitionLayer';
import styles from './SecondaryScreenShell.module.scss';
export type SecondaryScreenShellProps = {
@@ -10,6 +12,9 @@ export type SecondaryScreenShellProps = {
backLabel?: string;
backAriaLabel?: string;
rightAction?: ReactNode;
hideTopBarBackButton?: boolean;
hideTopBarRightAction?: boolean;
floatingAction?: ReactNode;
isLoading?: boolean;
loadingLabel?: ReactNode;
className?: string;
@@ -25,6 +30,9 @@ export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenSh
backLabel = 'Back',
backAriaLabel,
rightAction,
hideTopBarBackButton = false,
hideTopBarRightAction = false,
floatingAction,
isLoading = false,
loadingLabel = 'Loading...',
className = '',
@@ -34,45 +42,94 @@ export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenSh
ref
) {
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
const contentClasses = [
styles.content,
floatingAction ? styles.contentWithFloatingAction : '',
contentClassName,
]
.filter(Boolean)
.join(' ');
const titleTooltip = typeof title === 'string' ? title : undefined;
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const shouldRenderFloatingAction = Boolean(floatingAction) && isCurrentLayer;
const floatingActionRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (!shouldRenderFloatingAction) return;
const element = floatingActionRef.current;
if (!element) return;
const updateHeight = () => {
const height = element.getBoundingClientRect().height;
document.documentElement.style.setProperty(
'--secondary-shell-floating-action-height',
`${height}px`
);
};
updateHeight();
window.addEventListener('resize', updateHeight);
const resizeObserver =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
resizeObserver?.observe(element);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateHeight);
document.documentElement.style.removeProperty('--secondary-shell-floating-action-height');
};
}, [shouldRenderFloatingAction]);
return (
<div className={containerClassName} ref={ref}>
<div className={styles.topBar}>
{onBack ? (
<Button
variant="ghost"
size="sm"
onClick={onBack}
className={styles.backButton}
aria-label={resolvedBackAriaLabel}
>
<span className={styles.backIcon}>
<IconChevronLeft size={18} />
</span>
<span className={styles.backText}>{backLabel}</span>
</Button>
) : (
<div />
)}
<div className={styles.topBarTitle} title={titleTooltip}>
{title}
<>
<div className={containerClassName} ref={ref}>
<div className={styles.topBar}>
{onBack && !hideTopBarBackButton ? (
<Button
variant="ghost"
size="sm"
onClick={onBack}
className={styles.backButton}
aria-label={resolvedBackAriaLabel}
>
<span className={styles.backIcon}>
<IconChevronLeft size={18} />
</span>
<span className={styles.backText}>{backLabel}</span>
</Button>
) : (
<div />
)}
<div className={styles.topBarTitle} title={titleTooltip}>
{title}
</div>
<div className={styles.rightSlot}>{hideTopBarRightAction ? null : rightAction}</div>
</div>
<div className={styles.rightSlot}>{rightAction}</div>
</div>
{isLoading ? (
<div className={styles.loadingState}>
<LoadingSpinner size={16} />
<span>{loadingLabel}</span>
</div>
) : (
<div className={contentClasses}>{children}</div>
)}
</div>
{isLoading ? (
<div className={styles.loadingState}>
<LoadingSpinner size={16} />
<span>{loadingLabel}</span>
</div>
) : (
<div className={contentClasses}>{children}</div>
)}
</div>
{shouldRenderFloatingAction && typeof document !== 'undefined'
? createPortal(
<div className={styles.floatingActionContainer}>
<div className={styles.floatingActionSurface} ref={floatingActionRef}>
{floatingAction}
</div>
</div>,
document.body
)
: null}
</>
);
}
);
@@ -19,7 +19,7 @@ export const useProviderStats = () => {
}, [loadUsageStats]);
useInterval(() => {
void refreshKeyStats();
void refreshKeyStats().catch(() => {});
}, 240_000);
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading };
+3 -1
View File
@@ -23,12 +23,14 @@ export const withoutDisableAllModelsRule = (models?: string[]) => {
return base;
};
export const parseExcludedModels = (text: string): string[] =>
export const parseTextList = (text: string): string[] =>
text
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
export const parseExcludedModels = parseTextList;
export const excludedModelsToText = (models?: string[]) =>
Array.isArray(models) ? models.join('\n') : '';
+5 -2
View File
@@ -61,6 +61,7 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
quota?: TState;
resolvedTheme: ResolvedTheme;
i18nPrefix: string;
cardIdleMessageKey?: string;
cardClassName: string;
defaultType: string;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
@@ -71,6 +72,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
quota,
resolvedTheme,
i18nPrefix,
cardIdleMessageKey,
cardClassName,
defaultType,
renderQuotaItems
@@ -88,6 +90,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const idleMessageKey = cardIdleMessageKey ?? `${i18nPrefix}.idle`;
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
@@ -117,7 +120,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
@@ -127,7 +130,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
) : quota ? (
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
) : (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
)}
</div>
</div>
+1
View File
@@ -271,6 +271,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardIdleMessageKey={config.cardIdleMessageKey}
cardClassName={config.cardClassName}
defaultType={config.type}
renderQuotaItems={config.renderQuotaItems}
+10 -5
View File
@@ -34,7 +34,6 @@ import {
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeGeminiCliModelId,
normalizeNumberValue,
normalizePlanType,
@@ -60,6 +59,7 @@ import {
isGeminiCliFile,
isRuntimeOnlyAuthFile,
} from '@/utils/quota';
import { normalizeAuthIndex } from '@/utils/usage';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
@@ -84,6 +84,7 @@ export interface QuotaStore {
export interface QuotaConfig<TState, TData> {
type: QuotaType;
i18nPrefix: string;
cardIdleMessageKey?: string;
filterFn: (file: AuthFileItem) => boolean;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
storeSelector: (state: QuotaStore) => Record<string, TState>;
@@ -135,7 +136,7 @@ const fetchAntigravityQuota = async (
t: TFunction
): Promise<AntigravityQuotaGroup[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('antigravity_quota.missing_auth_index'));
}
@@ -377,7 +378,7 @@ const fetchCodexQuota = async (
t: TFunction
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('codex_quota.missing_auth_index'));
}
@@ -419,7 +420,7 @@ const fetchGeminiCliQuota = async (
t: TFunction
): Promise<GeminiCliQuotaBucketState[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('gemini_cli_quota.missing_auth_index'));
}
@@ -667,7 +668,7 @@ const fetchClaudeQuota = async (
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
@@ -758,6 +759,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchClaudeQuota,
storeSelector: (state) => state.claudeQuota,
@@ -784,6 +786,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota,
@@ -809,6 +812,7 @@ export const CODEX_CONFIG: QuotaConfig<
> = {
type: 'codex',
i18nPrefix: 'codex_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota,
@@ -835,6 +839,7 @@ export const CODEX_CONFIG: QuotaConfig<
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
filterFn: (file) =>
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchGeminiCliQuota,
+40 -47
View File
@@ -2,14 +2,15 @@ import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import {
computeKeyStats,
collectUsageDetails,
buildCandidateUsageSourceIds,
formatCompactNumber
formatCompactNumber,
normalizeAuthIndex
} from '@/utils/usage';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
@@ -23,11 +24,6 @@ export interface CredentialStatsCardProps {
openaiProviders: OpenAIProviderConfig[];
}
interface CredentialInfo {
name: string;
type: string;
}
interface CredentialRow {
key: string;
displayName: string;
@@ -43,17 +39,6 @@ interface CredentialBucket {
failure: number;
}
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
return null;
}
export function CredentialStatsCard({
usage,
loading,
@@ -78,7 +63,7 @@ export function CredentialStatsCard({
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const key = normalizeAuthIndexValue(rawAuthIndex);
const key = normalizeAuthIndex(rawAuthIndex);
if (key) {
map.set(key, {
name: file.name || key,
@@ -96,14 +81,49 @@ export function CredentialStatsCard({
// Auth files are used purely for name resolution of unmatched source IDs.
const rows = useMemo((): CredentialRow[] => {
if (!usage) return [];
const { bySource } = computeKeyStats(usage);
const details = collectUsageDetails(usage);
const bySource: Record<string, CredentialBucket> = {};
const result: CredentialRow[] = [];
const consumedSourceIds = new Set<string>();
const authIndexToRowIndex = new Map<string, number>();
const sourceToAuthIndex = new Map<string, string>();
const sourceToAuthFile = new Map<string, CredentialInfo>();
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
details.forEach((detail) => {
const authIdx = normalizeAuthIndex(detail.auth_index);
const source = detail.source;
const isFailed = detail.failed === true;
if (!source) {
if (!authIdx) return;
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
if (isFailed) {
fallback.failure += 1;
} else {
fallback.success += 1;
}
fallbackByAuthIndex.set(authIdx, fallback);
return;
}
const bucket = bySource[source] ?? { success: 0, failure: 0 };
if (isFailed) {
bucket.failure += 1;
} else {
bucket.success += 1;
}
bySource[source] = bucket;
if (authIdx && !sourceToAuthIndex.has(source)) {
sourceToAuthIndex.set(source, authIdx);
}
if (authIdx && !sourceToAuthFile.has(source)) {
const mapped = authFileMap.get(authIdx);
if (mapped) sourceToAuthFile.set(source, mapped);
}
});
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
const target = result[index];
if (!target) return;
@@ -191,33 +211,6 @@ export function CredentialStatsCard({
}
});
// Build source → auth file name mapping for remaining unmatched entries.
// Also collect fallback stats for details without source but with auth_index.
const sourceToAuthFile = new Map<string, CredentialInfo>();
details.forEach((d) => {
const authIdx = normalizeAuthIndexValue(d.auth_index);
if (!d.source) {
if (!authIdx) return;
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
if (d.failed === true) {
fallback.failure += 1;
} else {
fallback.success += 1;
}
fallbackByAuthIndex.set(authIdx, fallback);
return;
}
if (!authIdx || consumedSourceIds.has(d.source)) return;
if (!sourceToAuthIndex.has(d.source)) {
sourceToAuthIndex.set(d.source, authIdx);
}
if (!sourceToAuthFile.has(d.source)) {
const mapped = authFileMap.get(authIdx);
if (mapped) sourceToAuthFile.set(d.source, mapped);
}
});
// Remaining unmatched bySource entries — resolve name from auth files if possible
Object.entries(bySource).forEach(([key, bucket]) => {
if (consumedSourceIds.has(key)) return;
+36 -115
View File
@@ -7,11 +7,14 @@ import { Select } from '@/components/ui/Select';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import {
buildCandidateUsageSourceIds,
collectUsageDetails,
extractTotalTokens
extractTotalTokens,
normalizeAuthIndex
} from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
import styles from '@/pages/UsagePage.module.scss';
const ALL_FILTER = '__all__';
@@ -53,40 +56,11 @@ const toNumber = (value: unknown): number => {
const encodeCsv = (value: string | number): string => {
const text = String(value ?? '');
return `"${text.replace(/"/g, '""')}"`;
const trimmedLeft = text.replace(/^\s+/, '');
const safeText = trimmedLeft && /^[=+\-@]/.test(trimmedLeft) ? `'${text}` : text;
return `"${safeText.replace(/"/g, '""')}"`;
};
const downloadFile = (filename: string, content: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
};
type CredentialInfo = {
name: string;
type: string;
};
type SourceInfo = {
displayName: string;
type: string;
};
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
return null;
}
export function RequestEventsDetailsCard({
usage,
loading,
@@ -113,7 +87,7 @@ export function RequestEventsDetailsCard({
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const key = normalizeAuthIndexValue(file['auth_index'] ?? file.authIndex);
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
@@ -128,83 +102,28 @@ export function RequestEventsDetailsCard({
};
}, []);
const sourceInfoMap = useMemo(() => {
const map = new Map<string, SourceInfo>();
const registerSource = (sourceId: string, displayName: string, type: string) => {
if (!sourceId || !displayName) return;
if (map.has(sourceId)) return;
map.set(sourceId, { displayName, type });
};
const registerCandidates = (
displayName: string,
type: string,
candidates: string[]
) => {
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
};
geminiKeys.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Gemini #${index + 1}`;
registerCandidates(
displayName,
'gemini',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
claudeConfigs.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Claude #${index + 1}`;
registerCandidates(
displayName,
'claude',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
codexConfigs.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Codex #${index + 1}`;
registerCandidates(
displayName,
'codex',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
vertexConfigs.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Vertex #${index + 1}`;
registerCandidates(
displayName,
'vertex',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
openaiProviders.forEach((provider, providerIndex) => {
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((sourceId) =>
candidates.add(sourceId)
);
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((sourceId) =>
candidates.add(sourceId)
);
});
registerCandidates(displayName, 'openai', Array.from(candidates));
});
return map;
}, [claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]);
const sourceInfoMap = useMemo(
() =>
buildSourceInfoMap({
geminiApiKeys: geminiKeys,
claudeApiKeys: claudeConfigs,
codexApiKeys: codexConfigs,
vertexApiKeys: vertexConfigs,
openaiCompatibility: openaiProviders,
}),
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
);
const rows = useMemo<RequestEventRow[]>(() => {
const details = collectUsageDetails(usage);
return details
.map((detail, index) => {
const timestamp = typeof detail.timestamp === 'string' ? detail.timestamp : '';
const timestampMs = Date.parse(timestamp);
const timestamp = detail.timestamp;
const timestampMs =
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
? detail.__timestampMs
: Date.parse(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const sourceRaw = String(detail.source ?? '').trim();
const authIndexRaw = detail.auth_index as unknown;
@@ -212,13 +131,9 @@ export function RequestEventsDetailsCard({
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const normalizedAuthIndex = normalizeAuthIndexValue(authIndexRaw);
const sourceInfo = sourceInfoMap.get(sourceRaw);
const authInfo = normalizedAuthIndex ? authFileMap.get(normalizedAuthIndex) : undefined;
const source = sourceInfo?.displayName
|| authInfo?.name
|| (sourceRaw.startsWith('t:') ? sourceRaw.slice(2) : sourceRaw || '-');
const sourceType = sourceInfo?.type || authInfo?.type || '';
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
const source = sourceInfo.displayName;
const sourceType = sourceInfo.type;
const model = String(detail.__modelName ?? '').trim() || '-';
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
@@ -370,7 +285,10 @@ export function RequestEventsDetailsCard({
const content = [csvHeader.join(','), ...csvRows].join('\n');
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(`usage-events-${fileTime}.csv`, content, 'text/csv;charset=utf-8');
downloadBlob({
filename: `usage-events-${fileTime}.csv`,
blob: new Blob([content], { type: 'text/csv;charset=utf-8' })
});
};
const handleExportJson = () => {
@@ -394,7 +312,10 @@ export function RequestEventsDetailsCard({
const content = JSON.stringify(payload, null, 2);
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(`usage-events-${fileTime}.json`, content, 'application/json;charset=utf-8');
downloadBlob({
filename: `usage-events-${fileTime}.json`,
blob: new Blob([content], { type: 'application/json;charset=utf-8' })
});
};
return (
+63 -10
View File
@@ -1,4 +1,4 @@
import type { CSSProperties, ReactNode } from 'react';
import { useMemo, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
@@ -6,9 +6,9 @@ import {
formatCompactNumber,
formatPerMinuteValue,
formatUsd,
calculateTokenBreakdown,
calculateRecentPerMinuteRates,
calculateTotalCost,
calculateCost,
collectUsageDetails,
extractTotalTokens,
type ModelPrice
} from '@/utils/usage';
import { sparklineOptions } from '@/utils/usage/chartConfig';
@@ -32,6 +32,7 @@ export interface StatCardsProps {
usage: UsagePayload | null;
loading: boolean;
modelPrices: Record<string, ModelPrice>;
nowMs: number;
sparklines: {
requests: SparklineBundle | null;
tokens: SparklineBundle | null;
@@ -41,16 +42,68 @@ export interface StatCardsProps {
};
}
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
const { t } = useTranslation();
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage
? calculateRecentPerMinuteRates(30, usage)
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
const hasPrices = Object.keys(modelPrices).length > 0;
const { tokenBreakdown, rateStats, totalCost } = useMemo(() => {
const empty = {
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
totalCost: 0
};
if (!usage) return empty;
const details = collectUsageDetails(usage);
if (!details.length) return empty;
let cachedTokens = 0;
let reasoningTokens = 0;
let totalCost = 0;
const now = nowMs;
const windowMinutes = 30;
const windowStart = now - windowMinutes * 60 * 1000;
let requestCount = 0;
let tokenCount = 0;
const hasValidNow = Number.isFinite(now) && now > 0;
details.forEach((detail) => {
const tokens = detail.tokens;
cachedTokens += Math.max(
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
);
if (typeof tokens.reasoning_tokens === 'number') {
reasoningTokens += tokens.reasoning_tokens;
}
const timestamp = detail.__timestampMs ?? 0;
if (hasValidNow && Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= now) {
requestCount += 1;
tokenCount += extractTotalTokens(detail);
}
if (hasPrices) {
totalCost += calculateCost(detail, modelPrices);
}
});
const denominator = windowMinutes > 0 ? windowMinutes : 1;
return {
tokenBreakdown: { cachedTokens, reasoningTokens },
rateStats: {
rpm: requestCount / denominator,
tpm: tokenCount / denominator,
windowMinutes,
requestCount,
tokenCount
},
totalCost
};
}, [hasPrices, modelPrices, nowMs, usage]);
const statsCards: StatCardData[] = [
{
key: 'requests',
+69 -42
View File
@@ -24,6 +24,7 @@ export interface SparklineBundle {
export interface UseSparklinesOptions {
usage: UsagePayload | null;
loading: boolean;
nowMs: number;
}
export interface UseSparklinesReturn {
@@ -34,42 +35,43 @@ export interface UseSparklinesReturn {
costSparkline: SparklineBundle | null;
}
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
const buildLastHourSeries = useCallback(
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
if (!usage) return { labels: [], data: [] };
const details = collectUsageDetails(usage);
if (!details.length) return { labels: [], data: [] };
export function useSparklines({ usage, loading, nowMs }: UseSparklinesOptions): UseSparklinesReturn {
const lastHourSeries = useMemo(() => {
if (!usage) return { labels: [], requests: [], tokens: [] };
if (!Number.isFinite(nowMs) || nowMs <= 0) {
return { labels: [], requests: [], tokens: [] };
}
const details = collectUsageDetails(usage);
if (!details.length) return { labels: [], requests: [], tokens: [] };
const windowMinutes = 60;
const now = Date.now();
const windowStart = now - windowMinutes * 60 * 1000;
const buckets = new Array(windowMinutes).fill(0);
const windowMinutes = 60;
const now = nowMs;
const windowStart = now - windowMinutes * 60 * 1000;
const requestBuckets = new Array(windowMinutes).fill(0);
const tokenBuckets = new Array(windowMinutes).fill(0);
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart) {
return;
}
const minuteIndex = Math.min(
windowMinutes - 1,
Math.floor((timestamp - windowStart) / 60000)
);
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
buckets[minuteIndex] += increment;
});
details.forEach((detail) => {
const timestamp = detail.__timestampMs ?? 0;
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
const minuteIndex = Math.min(
windowMinutes - 1,
Math.floor((timestamp - windowStart) / 60000)
);
requestBuckets[minuteIndex] += 1;
tokenBuckets[minuteIndex] += extractTotalTokens(detail);
});
const labels = buckets.map((_, idx) => {
const date = new Date(windowStart + (idx + 1) * 60000);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
});
const labels = requestBuckets.map((_, idx) => {
const date = new Date(windowStart + (idx + 1) * 60000);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
});
return { labels, data: buckets };
},
[usage]
);
return { labels, requests: requestBuckets, tokens: tokenBuckets };
}, [nowMs, usage]);
const buildSparkline = useCallback(
(
@@ -104,28 +106,53 @@ export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSpar
);
const requestsSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#8b8680', 'rgba(139, 134, 128, 0.18)'),
[buildLastHourSeries, buildSparkline]
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.requests },
'#8b8680',
'rgba(139, 134, 128, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.requests]
);
const tokensSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
[buildLastHourSeries, buildSparkline]
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
'#8b5cf6',
'rgba(139, 92, 246, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
);
const rpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
[buildLastHourSeries, buildSparkline]
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.requests },
'#22c55e',
'rgba(34, 197, 94, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.requests]
);
const tpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
[buildLastHourSeries, buildSparkline]
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
'#f97316',
'rgba(249, 115, 22, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
);
const costSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
[buildLastHourSeries, buildSparkline]
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
'#f59e0b',
'rgba(245, 158, 11, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
);
return {
+15 -9
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import { downloadBlob } from '@/utils/download';
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
export interface UsagePayload {
@@ -48,7 +49,7 @@ export function useUsageData(): UseUsageDataReturn {
}, [loadUsageStats]);
useEffect(() => {
void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }).catch(() => {});
setModelPrices(loadModelPrices());
}, [loadUsageStats]);
@@ -62,13 +63,10 @@ export function useUsageData(): UseUsageDataReturn {
? new Date().toISOString()
: exportedAt.toISOString();
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
downloadBlob({
filename,
blob: new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' })
});
showNotification(t('usage_stats.export_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
@@ -111,7 +109,15 @@ export function useUsageData(): UseUsageDataReturn {
}),
'success'
);
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
try {
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
@@ -6,7 +6,7 @@ import { IconBot, IconCheck, IconCode, IconDownload, IconInfo, IconTrash2 } from
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { calculateStatusBarData, normalizeAuthIndex, type KeyStats } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import {
AUTH_FILE_REFRESH_WARNING_MS,
@@ -15,7 +15,6 @@ import {
getTypeColor,
getTypeLabel,
isRuntimeOnlyAuthFile,
normalizeAuthIndexValue,
resolveAuthFileStats,
type QuotaProviderType,
type ResolvedTheme
@@ -108,7 +107,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
const rawStatus = String(file.status ?? file['status'] ?? '')
+2 -13
View File
@@ -1,6 +1,7 @@
import type { TFunction } from 'i18next';
import type { AuthFileItem } from '@/types';
import {
normalizeAuthIndex,
normalizeUsageSourceId,
type KeyStatBucket,
type KeyStats
@@ -143,18 +144,6 @@ export const parseDisableCoolingValue = (value: unknown): boolean | undefined =>
return undefined;
};
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
export function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;
@@ -168,7 +157,7 @@ export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeySt
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
// 尝试根据 authIndex 匹配
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
@@ -6,6 +6,7 @@ import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import { downloadBlob } from '@/utils/download';
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
type DeleteAllOptions = {
@@ -316,12 +317,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
{ responseType: 'blob' }
);
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
downloadBlob({ filename: name, blob });
showNotification(t('auth_files.download_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import type { AuthFileItem } from '@/types';
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
import { calculateStatusBarData, normalizeAuthIndex, type UsageDetail } from '@/utils/usage';
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
@@ -11,11 +10,11 @@ export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails:
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
if (authIndexKey) {
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
const detailAuthIndex = normalizeAuthIndex(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
@@ -25,4 +24,3 @@ export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails:
return cache;
}, [files, usageDetails]);
}
+19 -4
View File
@@ -24,20 +24,34 @@ export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
const { showConfirmation } = useNotificationStore();
const lastBlockedRef = useRef<string>('');
const allowNextNavigationUntilRef = useRef(0);
const allowNextNavigationKeyRef = useRef('');
const location = useLocation();
const allowNextNavigation = useCallback(() => {
// Allow a short window for programmatic navigations after successful save.
// This avoids stale "allow" flags lingering when no navigation happens.
// Allow one programmatic navigation after successful save.
// A short window is used to avoid stale flags lingering when no navigation happens.
allowNextNavigationUntilRef.current = Date.now() + 2_000;
allowNextNavigationKeyRef.current = '';
}, []);
const shouldBlockFunction = useCallback<BlockerFunction>(
(args) => {
if (!enabled) return false;
if (allowNextNavigationUntilRef.current > Date.now()) {
return false;
const now = Date.now();
if (allowNextNavigationUntilRef.current > now) {
const nextKey = `${args.nextLocation.pathname}${args.nextLocation.search}${args.nextLocation.hash}`;
if (!allowNextNavigationKeyRef.current) {
allowNextNavigationKeyRef.current = nextKey;
}
if (allowNextNavigationKeyRef.current === nextKey) {
return false;
}
} else if (allowNextNavigationUntilRef.current !== 0) {
allowNextNavigationUntilRef.current = 0;
allowNextNavigationKeyRef.current = '';
}
return typeof shouldBlock === 'function' ? shouldBlock(args) : shouldBlock;
},
[enabled, shouldBlock]
@@ -48,6 +62,7 @@ export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
useEffect(() => {
if (allowNextNavigationUntilRef.current === 0) return;
allowNextNavigationUntilRef.current = 0;
allowNextNavigationKeyRef.current = '';
}, [location.key]);
const blockedKey = useMemo(() => {
+3 -2
View File
@@ -778,7 +778,7 @@
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
"gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project",
"gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.",
"gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account. Enter ALL to fetch all projects.",
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
"gemini_cli_oauth_url_label": "Authorization URL:",
"gemini_cli_open_link": "Open Link",
@@ -1229,7 +1229,8 @@
"title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files",
"refresh_files_and_quota": "Refresh files & quota"
"refresh_files_and_quota": "Refresh files & quota",
"card_idle_hint": "Use the top \"Refresh files & quota\" button to fetch the latest quota data."
},
"system_info": {
"title": "Management Center Info",
+3 -2
View File
@@ -781,7 +781,7 @@
"gemini_cli_oauth_hint": "Выполните вход в сервис Google Gemini CLI через OAuth и автоматически получите/сохраните файлы авторизации.",
"gemini_cli_project_id_label": "Google Cloud Project ID (необязательно):",
"gemini_cli_project_id_placeholder": "Оставьте пустым, чтобы выбрать первый доступный проект автоматически",
"gemini_cli_project_id_hint": "Необязательно. Если не указано, система автоматически выберет первый доступный проект вашей учётной записи.",
"gemini_cli_project_id_hint": "Необязательно. Если не указано, система автоматически выберет первый доступный проект вашей учётной записи. Введите ALL, чтобы получить все проекты.",
"gemini_cli_project_id_required": "Укажите идентификатор проекта Google Cloud.",
"gemini_cli_oauth_url_label": "URL авторизации:",
"gemini_cli_open_link": "Открыть ссылку",
@@ -1234,7 +1234,8 @@
"title": "Управление квотами",
"description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.",
"refresh_files": "Обновить файлы авторизации",
"refresh_files_and_quota": "Обновить файлы и квоты"
"refresh_files_and_quota": "Обновить файлы и квоты",
"card_idle_hint": "Используйте кнопку «Обновить файлы и квоты» сверху, чтобы загрузить актуальные данные по квотам."
},
"system_info": {
"title": "Информация о центре управления",
+3 -2
View File
@@ -778,7 +778,7 @@
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
"gemini_cli_project_id_placeholder": "留空将自动选择第一个可用项目",
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。",
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。输入 ALL 可获取全部项目。",
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
"gemini_cli_oauth_url_label": "授权链接:",
"gemini_cli_open_link": "打开链接",
@@ -1229,7 +1229,8 @@
"title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度"
"refresh_files_and_quota": "刷新认证文件&额度",
"card_idle_hint": "请使用顶部“刷新认证文件&额度”按钮获取最新额度。"
},
"system_info": {
"title": "管理中心信息",
+22 -4
View File
@@ -271,10 +271,28 @@ export function AiProvidersAmpcodeEditPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={() => void saveAmpcode()} loading={saving} disabled={!canSave}>
{t('common.save')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={() => void saveAmpcode()}
loading={saving}
disabled={!canSave}
className={layoutStyles.floatingSaveButton}
>
{t('common.save')}
</Button>
</div>
}
isLoading={loading}
loadingLabel={t('common.loading')}
+1 -14
View File
@@ -8,7 +8,7 @@ import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationS
import type { ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
@@ -63,19 +63,6 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeClaudeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
+51 -16
View File
@@ -13,7 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { useNotificationStore } from '@/stores';
import { buildHeaderObject } from '@/utils/headers';
import { buildClaudeMessagesEndpoint, parseExcludedModels } from '@/components/providers/utils';
import { buildClaudeMessagesEndpoint, parseTextList } from '@/components/providers/utils';
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -71,6 +71,7 @@ export function AiProvidersClaudeEditPage() {
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
const [isTesting, setIsTesting] = useState(false);
const lastCloakConfigRef = useRef<typeof form.cloak>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -82,6 +83,11 @@ export function AiProvidersClaudeEditPage() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]);
useEffect(() => {
if (!form.cloak) return;
lastCloakConfigRef.current = form.cloak;
}, [form.cloak]);
const canSave =
!disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTesting;
@@ -266,10 +272,28 @@ export function AiProvidersClaudeEditPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
{t('common.save')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={() => void handleSave()}
loading={saving}
disabled={!canSave}
className={layoutStyles.floatingSaveButton}
>
{t('common.save')}
</Button>
</div>
}
isLoading={loading}
loadingLabel={t('common.loading')}
@@ -460,16 +484,27 @@ export function AiProvidersClaudeEditPage() {
<ToggleSwitch
checked={Boolean(form.cloak)}
onChange={(enabled) =>
setForm((prev) => ({
...prev,
cloak: enabled
? {
mode: (prev.cloak?.mode ?? 'auto').trim() || 'auto',
strictMode: prev.cloak?.strictMode ?? false,
sensitiveWords: prev.cloak?.sensitiveWords ?? [],
}
: undefined,
}))
setForm((prev) => {
if (!enabled) {
if (prev.cloak) {
lastCloakConfigRef.current = prev.cloak;
}
return { ...prev, cloak: undefined };
}
const restored = prev.cloak
?? lastCloakConfigRef.current
?? { mode: 'auto', strictMode: false, sensitiveWords: [] };
const mode = String(restored.mode ?? 'auto').trim() || 'auto';
return {
...prev,
cloak: {
mode,
strictMode: restored.strictMode ?? false,
sensitiveWords: restored.sensitiveWords ?? [],
},
};
})
}
disabled={saving || disableControls || isTesting}
ariaLabel={t('ai_providers.claude_cloak_toggle_aria')}
@@ -527,7 +562,7 @@ export function AiProvidersClaudeEditPage() {
placeholder={t('ai_providers.claude_cloak_sensitive_words_placeholder')}
value={(form.cloak.sensitiveWords ?? []).join('\n')}
onChange={(e) => {
const nextWords = parseExcludedModels(e.target.value);
const nextWords = parseTextList(e.target.value);
setForm((prev) => ({
...prev,
cloak: {
+21 -4
View File
@@ -163,10 +163,27 @@ export function AiProvidersClaudeModelsPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={handleApply} disabled={!canApply}>
{t('ai_providers.claude_models_fetch_apply')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={handleApply}
disabled={!canApply}
className={layoutStyles.floatingSaveButton}
>
{t('ai_providers.claude_models_fetch_apply')}
</Button>
</div>
}
isLoading={initialLoading}
loadingLabel={t('common.loading')}
+32 -19
View File
@@ -14,7 +14,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import type { ProviderFormState } from '@/components/providers';
@@ -50,19 +50,6 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
@@ -118,6 +105,7 @@ export function AiProvidersCodexEditPage() {
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
const autoFetchSignatureRef = useRef<string>('');
const modelDiscoveryRequestIdRef = useRef(0);
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -266,6 +254,7 @@ export function AiProvidersCodexEditPage() {
);
const fetchCodexModelDiscovery = useCallback(async () => {
const requestId = (modelDiscoveryRequestIdRef.current += 1);
setModelDiscoveryFetching(true);
setModelDiscoveryError('');
@@ -280,19 +269,25 @@ export function AiProvidersCodexEditPage() {
hasCustomAuthorization ? undefined : apiKey,
headerObject
);
if (modelDiscoveryRequestIdRef.current !== requestId) return;
setDiscoveredModels(list);
} catch (err: unknown) {
if (modelDiscoveryRequestIdRef.current !== requestId) return;
setDiscoveredModels([]);
const message = getErrorMessage(err);
setModelDiscoveryError(`${t('ai_providers.codex_models_fetch_error')}: ${message}`);
} finally {
setModelDiscoveryFetching(false);
if (modelDiscoveryRequestIdRef.current === requestId) {
setModelDiscoveryFetching(false);
}
}
}, [form.apiKey, form.baseUrl, form.headers, t]);
useEffect(() => {
if (!modelDiscoveryOpen) {
autoFetchSignatureRef.current = '';
modelDiscoveryRequestIdRef.current += 1;
setModelDiscoveryFetching(false);
return;
}
@@ -422,10 +417,28 @@ export function AiProvidersCodexEditPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
{t('common.save')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!canSave}
className={layoutStyles.floatingSaveButton}
>
{t('common.save')}
</Button>
</div>
}
isLoading={loading}
loadingLabel={t('common.loading')}
@@ -4,6 +4,20 @@
margin: 0 auto;
}
.floatingActions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.floatingBackButton {
min-width: 82px;
}
.floatingSaveButton {
min-width: 88px;
}
.upstreamApiKeyRow {
display: flex;
align-items: center;
+32 -19
View File
@@ -13,7 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
@@ -45,19 +45,6 @@ const stripGeminiModelResourceName = (value: string) => {
return String(value ?? '').trim().replace(/^\/?models\//i, '');
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = stripGeminiModelResourceName(entry?.name ?? '').trim();
@@ -112,6 +99,7 @@ export function AiProvidersGeminiEditPage() {
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
const autoFetchSignatureRef = useRef<string>('');
const modelDiscoveryRequestIdRef = useRef(0);
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -244,6 +232,7 @@ export function AiProvidersGeminiEditPage() {
);
const fetchGeminiModelDiscovery = useCallback(async () => {
const requestId = (modelDiscoveryRequestIdRef.current += 1);
setModelDiscoveryFetching(true);
setModelDiscoveryError('');
const headerObject = buildHeaderObject(form.headers);
@@ -253,8 +242,10 @@ export function AiProvidersGeminiEditPage() {
form.apiKey.trim() || undefined,
headerObject
);
if (modelDiscoveryRequestIdRef.current !== requestId) return;
setDiscoveredModels(list);
} catch (err: unknown) {
if (modelDiscoveryRequestIdRef.current !== requestId) return;
setDiscoveredModels([]);
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const hasCustomXGoogApiKey = Object.keys(headerObject).some(
@@ -271,13 +262,17 @@ export function AiProvidersGeminiEditPage() {
: '';
setModelDiscoveryError(`${t('ai_providers.gemini_models_fetch_error')}: ${message}${diag}`);
} finally {
setModelDiscoveryFetching(false);
if (modelDiscoveryRequestIdRef.current === requestId) {
setModelDiscoveryFetching(false);
}
}
}, [form.apiKey, form.baseUrl, form.headers, t]);
useEffect(() => {
if (!modelDiscoveryOpen) {
autoFetchSignatureRef.current = '';
modelDiscoveryRequestIdRef.current += 1;
setModelDiscoveryFetching(false);
return;
}
@@ -416,10 +411,28 @@ export function AiProvidersGeminiEditPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
{t('common.save')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!canSave}
className={layoutStyles.floatingSaveButton}
>
{t('common.save')}
</Button>
</div>
}
isLoading={loading}
loadingLabel={t('common.loading')}
+1 -14
View File
@@ -8,7 +8,7 @@ import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftS
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
@@ -63,19 +63,6 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: ModelEntry[]) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
+22 -4
View File
@@ -501,10 +501,28 @@ export function AiProvidersOpenAIEditPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
{t('common.save')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={() => void handleSave()}
loading={saving}
disabled={!canSave}
className={layoutStyles.floatingSaveButton}
>
{t('common.save')}
</Button>
</div>
}
isLoading={loading}
loadingLabel={t('common.loading')}
+21 -4
View File
@@ -144,10 +144,27 @@ export function AiProvidersOpenAIModelsPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={handleApply} disabled={!canApply}>
{t('ai_providers.openai_models_fetch_apply')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={handleApply}
disabled={!canApply}
className={layoutStyles.floatingSaveButton}
>
{t('ai_providers.openai_models_fetch_apply')}
</Button>
</div>
}
isLoading={initialLoading}
loadingLabel={t('common.loading')}
+1 -1
View File
@@ -113,7 +113,7 @@ export function AiProvidersPage() {
if (hasMounted.current) return;
hasMounted.current = true;
loadConfigs();
loadKeyStats();
void loadKeyStats().catch(() => {});
}, [loadConfigs, loadKeyStats]);
useEffect(() => {
+29 -18
View File
@@ -13,7 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import type { VertexFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -35,19 +35,6 @@ const parseIndexParam = (value: string | undefined) => {
return Number.isFinite(parsed) ? parsed : null;
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
@@ -60,6 +47,8 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
const buildVertexSignature = (form: VertexFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
@@ -208,6 +197,10 @@ export function AiProvidersVertexEditPage() {
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: undefined,
prefix: form.prefix?.trim() || undefined,
baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined,
@@ -265,10 +258,28 @@ export function AiProvidersVertexEditPage() {
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
rightAction={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
{t('common.save')}
</Button>
hideTopBarBackButton
hideTopBarRightAction
floatingAction={
<div className={layoutStyles.floatingActions}>
<Button
variant="secondary"
size="sm"
onClick={handleBack}
className={layoutStyles.floatingBackButton}
>
{t('common.back')}
</Button>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!canSave}
className={layoutStyles.floatingSaveButton}
>
{t('common.save')}
</Button>
</div>
}
isLoading={loading}
loadingLabel={t('common.loading')}
+2 -2
View File
@@ -212,14 +212,14 @@ export function AuthFilesPage() {
useEffect(() => {
if (!isCurrentLayer) return;
loadFiles();
loadKeyStats();
void loadKeyStats().catch(() => {});
loadExcluded();
loadModelAlias();
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
useInterval(
() => {
void refreshKeyStats();
void refreshKeyStats().catch(() => {});
},
isCurrentLayer ? 240_000 : null
);
+7
View File
@@ -612,6 +612,13 @@
color: var(--text-primary);
}
.traceCandidatesHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.traceInfoGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+126 -871
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -150,6 +150,13 @@
margin-bottom: 0;
gap: $spacing-sm;
}
:global(.input:disabled) {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-tertiary);
cursor: not-allowed;
}
}
.formItem {
+9 -2
View File
@@ -153,8 +153,14 @@ export function OAuthPage() {
};
const startAuth = async (provider: OAuthProvider) => {
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
// 项目 ID 现在是可选的,如果不输入将自动选择第一个可用项目
const geminiState = provider === 'gemini-cli' ? states[provider] : undefined;
const rawProjectId = provider === 'gemini-cli' ? (geminiState?.projectId || '').trim() : '';
const projectId = rawProjectId
? rawProjectId.toUpperCase() === 'ALL'
? 'ALL'
: rawProjectId
: undefined;
// 项目 ID 可选:留空自动选择第一个可用项目;输入 ALL 获取全部项目
if (provider === 'gemini-cli') {
updateProviderState(provider, { projectIdError: undefined });
}
@@ -367,6 +373,7 @@ export function OAuthPage() {
hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
disabled={Boolean(state.polling)}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
+5 -2
View File
@@ -187,6 +187,8 @@ export function UsagePage() {
}
}, [timeRange]);
const nowMs = lastRefreshedAt?.getTime() ?? 0;
// Sparklines hook
const {
requestsSparkline,
@@ -194,7 +196,7 @@ export function UsagePage() {
rpmSparkline,
tpmSparkline,
costSparkline
} = useSparklines({ usage: filteredUsage, loading });
} = useSparklines({ usage: filteredUsage, loading, nowMs });
// Chart data hook
const {
@@ -266,7 +268,7 @@ export function UsagePage() {
<Button
variant="secondary"
size="sm"
onClick={loadUsage}
onClick={() => void loadUsage().catch(() => {})}
disabled={loading || exporting || importing}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
@@ -293,6 +295,7 @@ export function UsagePage() {
usage={filteredUsage}
loading={loading}
modelPrices={modelPrices}
nowMs={nowMs}
sparklines={{
requests: requestsSparkline,
tokens: tokensSparkline,
+275
View File
@@ -0,0 +1,275 @@
import { HTTP_METHODS, type HttpMethod, type LogLevel, type ParsedLogLine } from './logTypes';
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX =
/\b(?:\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))(?:\s*\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))*\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
const HTTP_STATUS_PATTERNS: RegExp[] = [
/\|\s*([1-5]\d{2})\s*\|/,
/\b([1-5]\d{2})\s*-/,
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
];
const detectHttpStatusCode = (text: string): number | undefined => {
for (const pattern of HTTP_STATUS_PATTERNS) {
const match = text.match(pattern);
if (!match) continue;
const code = Number.parseInt(match[1], 10);
if (!Number.isFinite(code)) continue;
if (code >= 100 && code <= 599) return code;
}
return undefined;
};
const extractIp = (text: string): string | undefined => {
const ipv4Match = text.match(LOG_IPV4_REGEX);
if (ipv4Match) return ipv4Match[0];
const ipv6Match = text.match(LOG_IPV6_REGEX);
if (!ipv6Match) return undefined;
const candidate = ipv6Match[0];
// Avoid treating time strings like "12:34:56" as IPv6 addresses.
if (LOG_TIME_OF_DAY_REGEX.test(candidate)) return undefined;
// If no compression marker is present, a valid IPv6 address must contain 8 hextets.
if (!candidate.includes('::') && candidate.split(':').length !== 8) return undefined;
return candidate;
};
const normalizeTimestampToSeconds = (value: string): string => {
const trimmed = value.trim();
const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (!match) return trimmed;
return `${match[1]} ${match[2]}`;
};
const extractLatency = (text: string): string | undefined => {
const match = text.match(LOG_LATENCY_REGEX);
if (!match) return undefined;
return match[0].replace(/\s+/g, '');
};
const extractLogLevel = (value: string): LogLevel | undefined => {
const normalized = value.trim().toLowerCase();
if (normalized === 'warning') return 'warn';
if (normalized === 'warn') return 'warn';
if (normalized === 'info') return 'info';
if (normalized === 'error') return 'error';
if (normalized === 'fatal') return 'fatal';
if (normalized === 'debug') return 'debug';
if (normalized === 'trace') return 'trace';
return undefined;
};
const inferLogLevel = (line: string): LogLevel | undefined => {
const lowered = line.toLowerCase();
if (/\bfatal\b/.test(lowered)) return 'fatal';
if (/\berror\b/.test(lowered)) return 'error';
if (/\bwarn(?:ing)?\b/.test(lowered) || line.includes('警告')) return 'warn';
if (/\binfo\b/.test(lowered)) return 'info';
if (/\bdebug\b/.test(lowered)) return 'debug';
if (/\btrace\b/.test(lowered)) return 'trace';
return undefined;
};
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
const match = text.match(HTTP_METHOD_REGEX);
if (!match) return {};
const method = match[1] as HttpMethod;
const index = match.index ?? 0;
const after = text.slice(index + match[0].length).trim();
const path = after ? after.split(/\s+/)[0] : undefined;
return { method, path };
};
export const parseLogLine = (raw: string): ParsedLogLine => {
let remaining = raw.trim();
let timestamp: string | undefined;
const tsMatch = remaining.match(LOG_TIMESTAMP_REGEX);
if (tsMatch) {
timestamp = tsMatch[1];
remaining = remaining.slice(tsMatch[0].length).trim();
}
let requestId: string | undefined;
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
if (requestIdMatch) {
const id = requestIdMatch[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
remaining = remaining.slice(requestIdMatch[0].length).trim();
}
let level: LogLevel | undefined;
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
if (lvlMatch) {
level = extractLogLevel(lvlMatch[1]);
remaining = remaining.slice(lvlMatch[0].length).trim();
}
let source: string | undefined;
const sourceMatch = remaining.match(LOG_SOURCE_REGEX);
if (sourceMatch) {
source = sourceMatch[1];
remaining = remaining.slice(sourceMatch[0].length).trim();
}
let statusCode: number | undefined;
let latency: string | undefined;
let ip: string | undefined;
let method: HttpMethod | undefined;
let path: string | undefined;
let message = remaining;
if (remaining.includes('|')) {
const segments = remaining
.split('|')
.map((segment) => segment.trim())
.filter(Boolean);
const consumed = new Set<number>();
const ginIndex = segments.findIndex((segment) => GIN_TIMESTAMP_SEGMENT_REGEX.test(segment));
if (ginIndex >= 0) {
const match = segments[ginIndex].match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
const normalizedGin = normalizeTimestampToSeconds(ginTimestamp);
const normalizedParsed = timestamp ? normalizeTimestampToSeconds(timestamp) : undefined;
if (!timestamp) {
timestamp = ginTimestamp;
consumed.add(ginIndex);
} else if (normalizedParsed === normalizedGin) {
consumed.add(ginIndex);
}
}
}
// request id (8-char hex or dashes)
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
if (requestIdIndex >= 0) {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
consumed.add(requestIdIndex);
}
}
// status code
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
if (statusIndex >= 0) {
const match = segments[statusIndex].match(/^(\d{3})$/);
if (match) {
const code = Number.parseInt(match[1], 10);
if (code >= 100 && code <= 599) {
statusCode = code;
consumed.add(statusIndex);
}
}
}
// latency
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
if (latencyIndex >= 0) {
const extracted = extractLatency(segments[latencyIndex]);
if (extracted) {
latency = extracted;
consumed.add(latencyIndex);
}
}
// ip
const ipIndex = segments.findIndex((segment) => Boolean(extractIp(segment)));
if (ipIndex >= 0) {
const extracted = extractIp(segments[ipIndex]);
if (extracted) {
ip = extracted;
consumed.add(ipIndex);
}
}
// method + path
const methodIndex = segments.findIndex((segment) => {
const { method: parsedMethod } = extractHttpMethodAndPath(segment);
return Boolean(parsedMethod);
});
if (methodIndex >= 0) {
const parsed = extractHttpMethodAndPath(segments[methodIndex]);
method = parsed.method;
path = parsed.path;
consumed.add(methodIndex);
}
// source (e.g. [gin_logger.go:94])
const sourceIndex = segments.findIndex((segment) => LOG_SOURCE_REGEX.test(segment));
if (sourceIndex >= 0) {
const match = segments[sourceIndex].match(LOG_SOURCE_REGEX);
if (match) {
source = match[1];
consumed.add(sourceIndex);
}
}
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
} else {
statusCode = detectHttpStatusCode(remaining);
const extracted = extractLatency(remaining);
if (extracted) latency = extracted;
ip = extractIp(remaining);
const parsed = extractHttpMethodAndPath(remaining);
method = parsed.method;
path = parsed.path;
}
if (!level) level = inferLogLevel(raw);
if (message) {
const match = message.match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
if (!timestamp) timestamp = ginTimestamp;
if (normalizeTimestampToSeconds(timestamp) === normalizeTimestampToSeconds(ginTimestamp)) {
message = '';
}
}
}
return {
raw,
timestamp,
level,
source,
requestId,
statusCode,
latency,
ip,
method,
path,
message,
};
};
+35
View File
@@ -0,0 +1,35 @@
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
export type HttpMethod = (typeof HTTP_METHODS)[number];
export const STATUS_GROUPS = ['2xx', '3xx', '4xx', '5xx'] as const;
export type StatusGroup = (typeof STATUS_GROUPS)[number];
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
export type LogState = {
buffer: string[];
visibleFrom: number;
};
export type ParsedLogLine = {
raw: string;
timestamp?: string;
level?: LogLevel;
source?: string;
requestId?: string;
statusCode?: number;
latency?: string;
ip?: string;
method?: HttpMethod;
path?: string;
message: string;
};
export const resolveStatusGroup = (statusCode?: number): StatusGroup | undefined => {
if (typeof statusCode !== 'number') return undefined;
if (statusCode >= 200 && statusCode < 300) return '2xx';
if (statusCode >= 300 && statusCode < 400) return '3xx';
if (statusCode >= 400 && statusCode < 500) return '4xx';
if (statusCode >= 500 && statusCode < 600) return '5xx';
return undefined;
};
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from 'react';
import type { HttpMethod, ParsedLogLine, StatusGroup } from './logTypes';
import { resolveStatusGroup } from './logTypes';
const PATH_FILTER_LIMIT = 12;
interface UseLogFiltersOptions {
parsedLines: ParsedLogLine[];
}
interface UseLogFiltersReturn {
methodFilters: HttpMethod[];
statusFilters: StatusGroup[];
pathFilters: string[];
methodFilterSet: Set<HttpMethod>;
statusFilterSet: Set<StatusGroup>;
pathFilterSet: Set<string>;
hasStructuredFilters: boolean;
methodCounts: Partial<Record<HttpMethod, number>>;
statusCounts: Partial<Record<StatusGroup, number>>;
pathOptions: Array<{ path: string; count: number }>;
toggleMethodFilter: (method: HttpMethod) => void;
toggleStatusFilter: (group: StatusGroup) => void;
togglePathFilter: (path: string) => void;
clearStructuredFilters: () => void;
}
export function useLogFilters(options: UseLogFiltersOptions): UseLogFiltersReturn {
const { parsedLines } = options;
const [methodFilters, setMethodFilters] = useState<HttpMethod[]>([]);
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
const [pathFilters, setPathFilters] = useState<string[]>([]);
const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]);
const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]);
const pathFilterSet = useMemo(() => new Set(pathFilters), [pathFilters]);
const hasStructuredFilters =
methodFilters.length > 0 || statusFilters.length > 0 || pathFilters.length > 0;
const methodCounts = useMemo(() => {
const counts: Partial<Record<HttpMethod, number>> = {};
parsedLines.forEach((line) => {
if (!line.method) return;
counts[line.method] = (counts[line.method] ?? 0) + 1;
});
return counts;
}, [parsedLines]);
const statusCounts = useMemo(() => {
const counts: Partial<Record<StatusGroup, number>> = {};
parsedLines.forEach((line) => {
const statusGroup = resolveStatusGroup(line.statusCode);
if (!statusGroup) return;
counts[statusGroup] = (counts[statusGroup] ?? 0) + 1;
});
return counts;
}, [parsedLines]);
const pathOptions = useMemo(() => {
const counts = new Map<string, number>();
parsedLines.forEach((line) => {
if (!line.path) return;
counts.set(line.path, (counts.get(line.path) ?? 0) + 1);
});
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, PATH_FILTER_LIMIT)
.map(([path, count]) => ({ path, count }));
}, [parsedLines]);
useEffect(() => {
const validPathSet = new Set(pathOptions.map((item) => item.path));
// eslint-disable-next-line react-hooks/set-state-in-effect
setPathFilters((prev) => {
if (prev.length === 0) return prev;
const next = prev.filter((path) => validPathSet.has(path));
return next.length === prev.length ? prev : next;
});
}, [pathOptions]);
const toggleMethodFilter = (method: HttpMethod) => {
setMethodFilters((prev) =>
prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method]
);
};
const toggleStatusFilter = (group: StatusGroup) => {
setStatusFilters((prev) =>
prev.includes(group) ? prev.filter((item) => item !== group) : [...prev, group]
);
};
const togglePathFilter = (path: string) => {
setPathFilters((prev) =>
prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path]
);
};
const clearStructuredFilters = () => {
setMethodFilters([]);
setStatusFilters([]);
setPathFilters([]);
};
return {
methodFilters,
statusFilters,
pathFilters,
methodFilterSet,
statusFilterSet,
pathFilterSet,
hasStructuredFilters,
methodCounts,
statusCounts,
pathOptions,
toggleMethodFilter,
toggleStatusFilter,
togglePathFilter,
clearStructuredFilters,
};
}
+168
View File
@@ -0,0 +1,168 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction, UIEvent } from 'react';
import type { LogState } from './logTypes';
const LOAD_MORE_LINES = 200;
const LOAD_MORE_THRESHOLD_PX = 72;
export const isNearBottom = (node: HTMLDivElement | null) => {
if (!node) return true;
const threshold = 24;
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
};
interface UseLogScrollerOptions {
logState: LogState;
setLogState: Dispatch<SetStateAction<LogState>>;
loading: boolean;
isSearching: boolean;
filteredLineCount: number;
hasStructuredFilters: boolean;
showRawLogs: boolean;
}
interface UseLogScrollerReturn {
logViewerRef: RefObject<HTMLDivElement | null>;
canLoadMore: boolean;
handleLogScroll: (e: UIEvent<HTMLDivElement>) => void;
scrollToBottom: () => void;
requestScrollToBottom: () => void;
}
export function useLogScroller(options: UseLogScrollerOptions): UseLogScrollerReturn {
const {
logState,
setLogState,
loading,
isSearching,
filteredLineCount,
hasStructuredFilters,
showRawLogs,
} = options;
const logViewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToBottomRef = useRef(false);
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
const canLoadMore = !isSearching && logState.visibleFrom > 0;
const scrollToBottom = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
node.scrollTop = node.scrollHeight;
}, []);
const requestScrollToBottom = useCallback(() => {
pendingScrollToBottomRef.current = true;
}, []);
const prependVisibleLines = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
if (pendingPrependScrollRef.current) return;
if (isSearching) return;
setLogState((prev) => {
if (prev.visibleFrom <= 0) {
return prev;
}
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
return {
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
};
});
}, [isSearching, setLogState]);
const handleLogScroll = useCallback(
(_e: UIEvent<HTMLDivElement>) => {
const node = logViewerRef.current;
if (!node) return;
if (isSearching) return;
if (!canLoadMore) return;
if (pendingPrependScrollRef.current) return;
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
prependVisibleLines();
},
[canLoadMore, isSearching, prependVisibleLines]
);
useLayoutEffect(() => {
const node = logViewerRef.current;
const pending = pendingPrependScrollRef.current;
if (!node || !pending) return;
const delta = node.scrollHeight - pending.scrollHeight;
node.scrollTop = pending.scrollTop + delta;
pendingPrependScrollRef.current = null;
}, [logState.visibleFrom]);
const tryAutoLoadMoreUntilScrollable = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
if (!canLoadMore) return;
if (isSearching) return;
if (pendingPrependScrollRef.current) return;
const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1;
if (hasVerticalOverflow) return;
prependVisibleLines();
}, [canLoadMore, isSearching, prependVisibleLines]);
useEffect(() => {
if (loading) return;
const node = logViewerRef.current;
if (!node) return;
const raf = window.requestAnimationFrame(() => {
tryAutoLoadMoreUntilScrollable();
});
return () => {
window.cancelAnimationFrame(raf);
};
}, [
filteredLineCount,
hasStructuredFilters,
loading,
logState.visibleFrom,
showRawLogs,
tryAutoLoadMoreUntilScrollable,
]);
useEffect(() => {
const onResize = () => {
window.requestAnimationFrame(() => {
tryAutoLoadMoreUntilScrollable();
});
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [tryAutoLoadMoreUntilScrollable]);
useEffect(() => {
if (!pendingScrollToBottomRef.current) return;
if (loading) return;
if (!logViewerRef.current) return;
scrollToBottom();
pendingScrollToBottomRef.current = false;
}, [loading, logState.buffer, logState.visibleFrom, scrollToBottom]);
return {
logViewerRef,
canLoadMore,
handleLogScroll,
scrollToBottom,
requestScrollToBottom,
};
}
+283
View File
@@ -0,0 +1,283 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api/authFiles';
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
import type { AuthFileItem, Config } from '@/types';
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import {
collectUsageDetailsWithEndpoint,
normalizeAuthIndex,
type UsageDetailWithEndpoint
} from '@/utils/usage';
import type { ParsedLogLine } from './logTypes';
type TraceConfidence = 'high' | 'medium' | 'low';
export type TraceCandidate = {
detail: UsageDetailWithEndpoint;
score: number;
confidence: TraceConfidence;
timeDeltaMs: number | null;
};
const TRACE_AUTH_CACHE_MS = 60 * 1000;
const TRACE_MATCH_STRONG_WINDOW_MS = 3 * 1000;
const TRACE_MATCH_WINDOW_MS = 10 * 1000;
const TRACE_MATCH_MAX_WINDOW_MS = 30 * 1000;
const TRACEABLE_EXACT_PATHS = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses']);
const TRACEABLE_PREFIX_PATHS = ['/v1beta/models'];
const normalizeTracePath = (value?: string) =>
String(value ?? '')
.replace(/^"+|"+$/g, '')
.split('?')[0]
.trim();
const normalizeTraceablePath = (value?: string): string => {
const normalized = normalizeTracePath(value);
if (!normalized || normalized === '/') return normalized;
return normalized.replace(/\/+$/, '');
};
export const isTraceableRequestPath = (value?: string): boolean => {
const normalizedPath = normalizeTraceablePath(value);
if (!normalizedPath) return false;
if (TRACEABLE_EXACT_PATHS.has(normalizedPath)) return true;
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
};
const scoreTraceCandidate = (
line: ParsedLogLine,
detail: UsageDetailWithEndpoint
): TraceCandidate | null => {
let score = 0;
let timeDeltaMs: number | null = null;
const logTimestampMs = line.timestamp ? Date.parse(line.timestamp) : Number.NaN;
const detailTimestampMs = detail.__timestampMs;
if (!Number.isNaN(logTimestampMs) && detailTimestampMs > 0) {
timeDeltaMs = Math.abs(logTimestampMs - detailTimestampMs);
if (timeDeltaMs <= TRACE_MATCH_STRONG_WINDOW_MS) {
score += 42;
} else if (timeDeltaMs <= TRACE_MATCH_WINDOW_MS) {
score += 30;
} else if (timeDeltaMs <= TRACE_MATCH_MAX_WINDOW_MS) {
score += 12;
} else {
score -= 12;
}
}
let methodMatched = false;
if (line.method && detail.__endpointMethod) {
if (line.method.toUpperCase() === detail.__endpointMethod.toUpperCase()) {
score += 18;
methodMatched = true;
} else {
score -= 8;
}
}
const logPath = normalizeTracePath(line.path);
const detailPath = normalizeTracePath(detail.__endpointPath);
let pathMatched = false;
if (logPath && detailPath) {
if (logPath === detailPath) {
score += 24;
pathMatched = true;
} else if (logPath.startsWith(detailPath) || detailPath.startsWith(logPath)) {
score += 12;
pathMatched = true;
} else {
score -= 8;
}
}
if (typeof line.statusCode === 'number') {
const logFailed = line.statusCode >= 400;
score += logFailed === detail.failed ? 10 : -6;
}
if (
timeDeltaMs !== null &&
timeDeltaMs > TRACE_MATCH_MAX_WINDOW_MS &&
!methodMatched &&
!pathMatched
) {
return null;
}
if (score <= 0) return null;
const confidence: TraceConfidence = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
return { detail, score, confidence, timeDeltaMs };
};
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (typeof err !== 'object' || err === null) return '';
if (!('message' in err)) return '';
const message = (err as { message?: unknown }).message;
return typeof message === 'string' ? message : '';
};
interface UseTraceResolverOptions {
traceScopeKey: string;
connectionStatus: string;
config: Config | null;
requestLogDownloading: boolean;
}
interface UseTraceResolverReturn {
traceLogLine: ParsedLogLine | null;
traceLoading: boolean;
traceError: string;
traceCandidates: TraceCandidate[];
resolveTraceSourceInfo: (sourceRaw: string, authIndex: unknown) => SourceInfo;
loadTraceUsageDetails: () => Promise<void>;
refreshTraceUsageDetails: () => Promise<void>;
openTraceModal: (line: ParsedLogLine) => void;
closeTraceModal: () => void;
}
export function useTraceResolver(options: UseTraceResolverOptions): UseTraceResolverReturn {
const { traceScopeKey, connectionStatus, config, requestLogDownloading } = options;
const { t } = useTranslation();
const usageSnapshot = useUsageStatsStore((state) => state.usage);
const usageScopeKey = useUsageStatsStore((state) => state.scopeKey);
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
const [traceLogLine, setTraceLogLine] = useState<ParsedLogLine | null>(null);
const [traceAuthFileMap, setTraceAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
const [traceLoading, setTraceLoading] = useState(false);
const [traceError, setTraceError] = useState('');
const traceAuthLoadedAtRef = useRef(0);
const traceScopeKeyRef = useRef('');
const scopedUsageSnapshot = usageScopeKey === traceScopeKey ? usageSnapshot : null;
const traceUsageDetails = useMemo<UsageDetailWithEndpoint[]>(
() => collectUsageDetailsWithEndpoint(scopedUsageSnapshot),
[scopedUsageSnapshot]
);
const traceSourceInfoMap = useMemo(() => buildSourceInfoMap(config ?? {}), [config]);
const loadTraceUsageDetailsInternal = useCallback(async (forceUsage: boolean) => {
if (traceScopeKeyRef.current !== traceScopeKey) {
traceScopeKeyRef.current = traceScopeKey;
traceAuthLoadedAtRef.current = 0;
setTraceAuthFileMap(new Map());
setTraceError('');
}
if (traceLoading) return;
const now = Date.now();
const authFresh =
traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_AUTH_CACHE_MS;
setTraceLoading(true);
setTraceError('');
try {
const [, authFilesResponse] = await Promise.all([
loadUsageStats({
force: forceUsage,
staleTimeMs: USAGE_STATS_STALE_TIME_MS
}),
authFresh ? Promise.resolve(null) : authFilesApi.list().catch(() => null)
]);
if (authFilesResponse !== null) {
const files = Array.isArray(authFilesResponse)
? authFilesResponse
: (authFilesResponse as { files?: AuthFileItem[] })?.files;
if (Array.isArray(files)) {
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString()
});
});
setTraceAuthFileMap(map);
traceAuthLoadedAtRef.current = Date.now();
}
}
} catch (err: unknown) {
setTraceError(getErrorMessage(err) || t('logs.trace_usage_load_error'));
} finally {
setTraceLoading(false);
}
}, [loadUsageStats, t, traceLoading, traceScopeKey]);
const loadTraceUsageDetails = useCallback(async () => {
await loadTraceUsageDetailsInternal(false);
}, [loadTraceUsageDetailsInternal]);
const refreshTraceUsageDetails = useCallback(async () => {
await loadTraceUsageDetailsInternal(true);
}, [loadTraceUsageDetailsInternal]);
useEffect(() => {
if (connectionStatus === 'connected') {
traceScopeKeyRef.current = traceScopeKey;
traceAuthLoadedAtRef.current = 0;
setTraceAuthFileMap(new Map());
setTraceLoading(false);
setTraceError('');
}
}, [connectionStatus, traceScopeKey]);
const traceCandidates = useMemo(() => {
if (!traceLogLine) return [];
const scored = traceUsageDetails
.map((detail) => scoreTraceCandidate(traceLogLine, detail))
.filter((item): item is TraceCandidate => item !== null)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const aDelta = a.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
const bDelta = b.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
return aDelta - bDelta;
});
return scored.slice(0, 8);
}, [traceLogLine, traceUsageDetails]);
const resolveTraceSourceInfo = useCallback(
(sourceRaw: string, authIndex: unknown): SourceInfo =>
resolveSourceDisplay(sourceRaw, authIndex, traceSourceInfoMap, traceAuthFileMap),
[traceAuthFileMap, traceSourceInfoMap]
);
const openTraceModal = useCallback(
(line: ParsedLogLine) => {
if (!isTraceableRequestPath(line.path)) return;
setTraceError('');
setTraceLogLine(line);
void loadTraceUsageDetails();
},
[loadTraceUsageDetails]
);
const closeTraceModal = useCallback(() => {
if (requestLogDownloading) return;
setTraceLogLine(null);
}, [requestLogDownloading]);
return {
traceLogLine,
traceLoading,
traceError,
traceCandidates,
resolveTraceSourceInfo,
loadTraceUsageDetails,
refreshTraceUsageDetails,
openTraceModal,
closeTraceModal
};
}
+7 -4
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { usageApi } from '@/services/api';
import { useAuthStore } from '@/stores/useAuthStore';
import { collectUsageDetails, computeKeyStats, type KeyStats, type UsageDetail } from '@/utils/usage';
import { collectUsageDetails, computeKeyStatsFromDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
import i18n from '@/i18n';
export const USAGE_STATS_STALE_TIME_MS = 240_000;
@@ -98,10 +98,11 @@ export const useUsageStatsStore = create<UsageStatsState>((set, get) => ({
if (requestId !== usageRequestToken) return;
const usageDetails = collectUsageDetails(usage);
set({
usage,
keyStats: computeKeyStats(usage),
usageDetails: collectUsageDetails(usage),
keyStats: computeKeyStatsFromDetails(usageDetails),
usageDetails,
loading: false,
error: null,
lastRefreshedAt: Date.now(),
@@ -109,11 +110,13 @@ export const useUsageStatsStore = create<UsageStatsState>((set, get) => ({
});
} catch (error: unknown) {
if (requestId !== usageRequestToken) return;
const message = getErrorMessage(error);
set({
loading: false,
error: getErrorMessage(error),
error: message,
scopeKey
});
throw new Error(message);
} finally {
if (inFlightUsageRequest?.id === requestId) {
inFlightUsageRequest = null;
+1
View File
@@ -13,3 +13,4 @@ export * from './oauth';
export * from './usage';
export * from './log';
export * from './quota';
export * from './sourceInfo';
+9
View File
@@ -0,0 +1,9 @@
export type SourceInfo = {
displayName: string;
type: string;
};
export type CredentialInfo = {
name: string;
type: string;
};
+22
View File
@@ -0,0 +1,22 @@
export type DownloadBlobOptions = {
filename: string;
blob: Blob;
revokeDelayMs?: number;
};
export function downloadBlob({ filename, blob, revokeDelayMs = 1000 }: DownloadBlobOptions) {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.rel = 'noopener';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
window.setTimeout(() => {
window.URL.revokeObjectURL(url);
link.remove();
}, revokeDelayMs);
}
+13
View File
@@ -37,3 +37,16 @@ export function headersToEntries(headers?: Record<string, string | undefined | n
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => ({ key, value: String(value) }));
}
export const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
+2 -2
View File
@@ -134,8 +134,8 @@ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
{
id: 'gemini-pro-series',
label: 'Gemini Pro Series',
preferredModelId: 'gemini-3-1-pro-preview',
modelIds: ['gemini-3-1-pro-preview', 'gemini-3-pro-preview', 'gemini-2.5-pro'],
preferredModelId: 'gemini-3.1-pro-preview',
modelIds: ['gemini-3.1-pro-preview', 'gemini-3-pro-preview', 'gemini-2.5-pro'],
},
];
+2 -11
View File
@@ -3,19 +3,10 @@
*/
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
import { normalizeAuthIndex } from '@/utils/usage';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
export function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
}
export { normalizeAuthIndex };
export function normalizeStringValue(value: unknown): string | null {
if (typeof value === 'string') {
+83
View File
@@ -0,0 +1,83 @@
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
import { buildCandidateUsageSourceIds, normalizeAuthIndex } from '@/utils/usage';
export interface SourceInfoMapInput {
geminiApiKeys?: GeminiKeyConfig[];
claudeApiKeys?: ProviderKeyConfig[];
codexApiKeys?: ProviderKeyConfig[];
vertexApiKeys?: ProviderKeyConfig[];
openaiCompatibility?: OpenAIProviderConfig[];
}
export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, SourceInfo> {
const map = new Map<string, SourceInfo>();
const registerSource = (sourceId: string, displayName: string, type: string) => {
if (!sourceId || !displayName || map.has(sourceId)) return;
map.set(sourceId, { displayName, type });
};
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
};
const providers: Array<{
items: Array<{ apiKey?: string; prefix?: string }>;
type: string;
label: string;
}> = [
{ items: input.geminiApiKeys || [], type: 'gemini', label: 'Gemini' },
{ items: input.claudeApiKeys || [], type: 'claude', label: 'Claude' },
{ items: input.codexApiKeys || [], type: 'codex', label: 'Codex' },
{ items: input.vertexApiKeys || [], type: 'vertex', label: 'Vertex' },
];
providers.forEach(({ items, type, label }) => {
items.forEach((item, index) => {
const displayName = item.prefix?.trim() || `${label} #${index + 1}`;
registerCandidates(
displayName,
type,
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
);
});
});
// OpenAI 特殊处理:多 apiKeyEntries
(input.openaiCompatibility || []).forEach((provider, providerIndex) => {
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
registerCandidates(displayName, 'openai', Array.from(candidates));
});
return map;
}
export function resolveSourceDisplay(
sourceRaw: string,
authIndex: unknown,
sourceInfoMap: Map<string, SourceInfo>,
authFileMap: Map<string, CredentialInfo>
): SourceInfo {
const source = sourceRaw.trim();
const matched = sourceInfoMap.get(source);
if (matched) return matched;
const authIndexKey = normalizeAuthIndex(authIndex);
if (authIndexKey) {
const authInfo = authFileMap.get(authIndexKey);
if (authInfo) {
return { displayName: authInfo.name || authIndexKey, type: authInfo.type };
}
}
return {
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
type: '',
};
}
+167 -61
View File
@@ -49,6 +49,7 @@ export interface UsageDetail {
};
failed: boolean;
__modelName?: string;
__timestampMs?: number;
}
export interface UsageDetailWithEndpoint extends UsageDetail {
@@ -109,34 +110,6 @@ const toUsageSummaryFields = (summary: UsageSummary) => ({
total_tokens: summary.totalTokens
});
const isDetailWithinWindow = (detail: unknown, windowStart: number, nowMs: number): detail is Record<string, unknown> => {
if (!isRecord(detail) || typeof detail.timestamp !== 'string') {
return false;
}
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return false;
}
return timestamp >= windowStart && timestamp <= nowMs;
};
const updateSummaryFromDetails = (summary: UsageSummary, details: unknown[]) => {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
if (!detailRecord) {
return;
}
summary.totalRequests += 1;
if (detailRecord.failed === true) {
summary.failureCount += 1;
} else {
summary.successCount += 1;
}
summary.totalTokens += extractTotalTokens(detailRecord);
});
};
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
if (range === 'all') {
return usageData;
@@ -169,6 +142,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
const filteredModels: Record<string, unknown> = {};
const apiSummary = createUsageSummary();
let hasModelData = false;
Object.entries(models).forEach(([modelName, modelEntry]) => {
if (!isRecord(modelEntry)) {
@@ -176,22 +150,39 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
}
const detailsRaw = Array.isArray(modelEntry.details) ? modelEntry.details : [];
const filteredDetails = detailsRaw.filter((detail) =>
isDetailWithinWindow(detail, windowStart, nowMs)
);
const modelSummary = createUsageSummary();
const filteredDetails: unknown[] = [];
detailsRaw.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
if (!detailRecord || typeof detailRecord.timestamp !== 'string') {
return;
}
const timestamp = Date.parse(detailRecord.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) {
return;
}
filteredDetails.push(detail);
modelSummary.totalRequests += 1;
if (detailRecord.failed === true) {
modelSummary.failureCount += 1;
} else {
modelSummary.successCount += 1;
}
modelSummary.totalTokens += extractTotalTokens(detailRecord);
});
if (!filteredDetails.length) {
return;
}
const modelSummary = createUsageSummary();
updateSummaryFromDetails(modelSummary, filteredDetails);
filteredModels[modelName] = {
...modelEntry,
...toUsageSummaryFields(modelSummary),
details: filteredDetails
};
hasModelData = true;
apiSummary.totalRequests += modelSummary.totalRequests;
apiSummary.successCount += modelSummary.successCount;
@@ -199,7 +190,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
apiSummary.totalTokens += modelSummary.totalTokens;
});
if (Object.keys(filteredModels).length === 0) {
if (!hasModelData) {
return;
}
@@ -222,7 +213,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
} as T;
}
const normalizeAuthIndex = (value: unknown) => {
export const normalizeAuthIndex = (value: unknown) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
@@ -450,13 +441,40 @@ export function formatUsd(value: number): string {
return `$${parts}`;
}
const usageDetailsCache = new WeakMap<object, UsageDetail[]>();
const usageDetailsWithEndpointCache = new WeakMap<object, UsageDetailWithEndpoint[]>();
/**
* 使
*/
export function collectUsageDetails(usageData: unknown): UsageDetail[] {
const cacheKey = isRecord(usageData) ? (usageData as object) : null;
if (cacheKey) {
const cached = usageDetailsCache.get(cacheKey);
if (cached) return cached;
}
const apis = getApisRecord(usageData);
if (!apis) return [];
const details: UsageDetail[] = [];
const sourceCache = new Map<string, string>();
const normalizeSource = (value: unknown): string => {
const raw =
typeof value === 'string'
? value
: value === null || value === undefined
? ''
: String(value);
const trimmed = raw.trim();
if (!trimmed) return '';
const cached = sourceCache.get(trimmed);
if (cached !== undefined) return cached;
const normalized = normalizeUsageSourceId(trimmed);
sourceCache.set(trimmed, normalized);
return normalized;
};
Object.values(apis).forEach((apiEntry) => {
if (!isRecord(apiEntry)) return;
const modelsRaw = apiEntry.models;
@@ -470,15 +488,25 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
modelDetails.forEach((detailRaw) => {
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
const detail = detailRaw as unknown as UsageDetail;
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
details.push({
...detail,
source: normalizeUsageSourceId(detail.source),
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
__modelName: modelName,
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
});
});
});
});
if (cacheKey) {
usageDetailsCache.set(cacheKey, details);
}
return details;
}
@@ -486,10 +514,34 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
* 使 endpoint/method/path
*/
export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetailWithEndpoint[] {
const cacheKey = isRecord(usageData) ? (usageData as object) : null;
if (cacheKey) {
const cached = usageDetailsWithEndpointCache.get(cacheKey);
if (cached) return cached;
}
const apis = getApisRecord(usageData);
if (!apis) return [];
const details: UsageDetailWithEndpoint[] = [];
const sourceCache = new Map<string, string>();
const normalizeSource = (value: unknown): string => {
const raw =
typeof value === 'string'
? value
: value === null || value === undefined
? ''
: String(value);
const trimmed = raw.trim();
if (!trimmed) return '';
const cached = sourceCache.get(trimmed);
if (cached !== undefined) return cached;
const normalized = normalizeUsageSourceId(trimmed);
sourceCache.set(trimmed, normalized);
return normalized;
};
Object.entries(apis).forEach(([endpoint, apiEntry]) => {
if (!isRecord(apiEntry)) return;
const modelsRaw = apiEntry.models;
@@ -507,11 +559,15 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
modelDetails.forEach((detailRaw) => {
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
const detail = detailRaw as unknown as UsageDetail;
const timestampMs = Date.parse(detail.timestamp);
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
details.push({
...detail,
source: normalizeUsageSourceId(detail.source),
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
__modelName: modelName,
__endpoint: endpoint,
__endpointMethod: endpointMethod,
@@ -522,6 +578,9 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
});
});
if (cacheKey) {
usageDetailsWithEndpointCache.set(cacheKey, details);
}
return details;
}
@@ -592,8 +651,9 @@ export function calculateRecentPerMinuteRates(
let tokenCount = 0;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart) {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
requestCount += 1;
@@ -951,8 +1011,9 @@ export function buildHourlySeriesByModel(
}
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
@@ -1007,8 +1068,9 @@ export function buildDailySeriesByModel(
}
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
const dayLabel = formatDayLabel(new Date(timestamp));
@@ -1220,8 +1282,9 @@ export function calculateStatusBarData(
// Filter and bucket the usage details
usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
return;
}
@@ -1322,8 +1385,9 @@ export function calculateServiceHealthData(
let totalFailure = 0;
usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
return;
}
@@ -1439,6 +1503,44 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
};
}
export function computeKeyStatsFromDetails(usageDetails: UsageDetail[]): KeyStats {
const bySource: Record<string, KeyStatBucket> = {};
const byAuthIndex: Record<string, KeyStatBucket> = {};
const ensureBucket = (bucket: Record<string, KeyStatBucket>, key: string) => {
if (!bucket[key]) {
bucket[key] = { success: 0, failure: 0 };
}
return bucket[key];
};
usageDetails.forEach((detail) => {
const source = detail.source;
const authIndexKey = normalizeAuthIndex(detail.auth_index);
const isFailed = detail.failed === true;
if (source) {
const bucket = ensureBucket(bySource, source);
if (isFailed) {
bucket.failure += 1;
} else {
bucket.success += 1;
}
}
if (authIndexKey) {
const bucket = ensureBucket(byAuthIndex, authIndexKey);
if (isFailed) {
bucket.failure += 1;
} else {
bucket.success += 1;
}
}
});
return { bySource, byAuthIndex };
}
export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning';
export interface TokenBreakdownSeries {
@@ -1483,8 +1585,9 @@ export function buildHourlyTokenBreakdown(
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
@@ -1521,8 +1624,9 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;
@@ -1594,8 +1698,9 @@ export function buildHourlyCostSeries(
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
@@ -1626,8 +1731,9 @@ export function buildDailyCostSeries(
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;