mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
17 Commits
@@ -23,6 +23,7 @@ skills
|
||||
|
||||
# Editor directories and files
|
||||
settings.local.json
|
||||
.codex
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
@@ -936,6 +936,15 @@ export function VisualConfigEditor({
|
||||
disabled={disabled}
|
||||
onChange={(quotaSwitchPreviewModel) => onChange({ quotaSwitchPreviewModel })}
|
||||
/>
|
||||
<ToggleRow
|
||||
title={t('config_management.visual.sections.quota.antigravity_credits')}
|
||||
description={t(
|
||||
'config_management.visual.sections.quota.antigravity_credits_desc'
|
||||
)}
|
||||
checked={values.quotaAntigravityCredits}
|
||||
disabled={disabled}
|
||||
onChange={(quotaAntigravityCredits) => onChange({ quotaAntigravityCredits })}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</ConfigSection>
|
||||
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface ClaudeSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function ClaudeSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function ClaudeSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function ClaudeSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -101,12 +103,16 @@ export function ClaudeSection({
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function CodexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function CodexSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function CodexSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -101,12 +103,16 @@ export function CodexSection({
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function GeminiSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function GeminiSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function GeminiSection({
|
||||
<ProviderList<GeminiKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -102,11 +104,15 @@ export function GeminiSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -7,21 +7,24 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
import {
|
||||
collectOpenAIProviderUsageDetails,
|
||||
getOpenAIEntryKey,
|
||||
getOpenAIProviderKey,
|
||||
getOpenAIProviderStats,
|
||||
getStatsForIdentity,
|
||||
} from '../utils';
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -35,6 +38,7 @@ export function OpenAISection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -49,21 +53,22 @@ export function OpenAISection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
configs.forEach((provider, index) => {
|
||||
const providerKey = getOpenAIProviderKey(provider, index);
|
||||
cache.set(
|
||||
providerKey,
|
||||
calculateStatusBarData(
|
||||
collectOpenAIProviderUsageDetails(
|
||||
provider,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,17 +92,18 @@ export function OpenAISection({
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(_, index) => `openai-provider-${index}`}
|
||||
keyField={(item, index) => getOpenAIProviderKey(item, index)}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getOpenAIProviderStats(item, keyStats);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -134,9 +140,15 @@ export function OpenAISection({
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||
const entryStats = getStatsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
keyStats
|
||||
);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<div
|
||||
key={getOpenAIEntryKey(entry, entryIndex)}
|
||||
className={styles.apiKeyEntryCard}
|
||||
>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface VertexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function VertexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function VertexSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function VertexSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.vertex_empty_title')}
|
||||
emptyDescription={t('ai_providers.vertex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -102,11 +104,15 @@ export function VertexSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type {
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping,
|
||||
AmpcodeUpstreamApiKeyMapping,
|
||||
ApiKeyEntry,
|
||||
OpenAIProviderConfig,
|
||||
} from '@/types';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
normalizeAuthIndex,
|
||||
type KeyStatBucket,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForAuthIndices,
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsByAuthIndex,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
@@ -109,25 +127,94 @@ export const getStatsBySource = (
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
providerPrefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
type UsageIdentity = {
|
||||
authIndex?: unknown;
|
||||
apiKey?: string;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
export const getStatsForIdentity = (
|
||||
identity: UsageIdentity,
|
||||
keyStats: KeyStats
|
||||
): KeyStatBucket => {
|
||||
const authIndexKey = normalizeAuthIndex(identity.authIndex);
|
||||
if (authIndexKey) {
|
||||
const stats = keyStats.byAuthIndex?.[authIndexKey];
|
||||
if (stats) {
|
||||
return { success: stats.success, failure: stats.failure };
|
||||
}
|
||||
}
|
||||
|
||||
return getStatsBySource(identity.apiKey ?? '', keyStats, identity.prefix);
|
||||
};
|
||||
|
||||
export const collectUsageDetailsForIdentity = (
|
||||
identity: UsageIdentity,
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
|
||||
): UsageDetail[] => {
|
||||
const authIndexKey = normalizeAuthIndex(identity.authIndex);
|
||||
if (authIndexKey) {
|
||||
const details = collectUsageDetailsForAuthIndices(usageDetailsByAuthIndex, [authIndexKey]);
|
||||
if (details.length > 0) {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: identity.apiKey,
|
||||
prefix: identity.prefix,
|
||||
});
|
||||
if (!candidates.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectUsageDetailsForCandidates(usageDetailsBySource, candidates);
|
||||
};
|
||||
|
||||
const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => {
|
||||
let firstDetails: UsageDetail[] | null = null;
|
||||
let merged: UsageDetail[] | null = null;
|
||||
|
||||
groups.forEach((details) => {
|
||||
if (!details.length) return;
|
||||
if (!firstDetails) {
|
||||
firstDetails = details;
|
||||
return;
|
||||
}
|
||||
if (!merged) {
|
||||
merged = [...firstDetails];
|
||||
}
|
||||
merged.push(...details);
|
||||
});
|
||||
|
||||
return merged ?? firstDetails ?? [];
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
provider: OpenAIProviderConfig,
|
||||
keyStats: KeyStats
|
||||
): KeyStatBucket => {
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
sourceIds.forEach((id) => {
|
||||
const stats = bySource[id];
|
||||
if (!stats) return;
|
||||
|
||||
if (!provider.apiKeyEntries?.length) {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: provider.authIndex, prefix: provider.prefix },
|
||||
keyStats
|
||||
);
|
||||
return { success: stats.success, failure: stats.failure };
|
||||
}
|
||||
|
||||
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
|
||||
const prefixStats = getStatsBySource('', keyStats, provider.prefix);
|
||||
success += prefixStats.success;
|
||||
failure += prefixStats.failure;
|
||||
}
|
||||
|
||||
provider.apiKeyEntries.forEach((entry) => {
|
||||
const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats);
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
@@ -135,6 +222,75 @@ export const getOpenAIProviderStats = (
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
export const collectOpenAIProviderUsageDetails = (
|
||||
provider: OpenAIProviderConfig,
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
|
||||
): UsageDetail[] => {
|
||||
if (!provider.apiKeyEntries?.length) {
|
||||
return collectUsageDetailsForIdentity(
|
||||
{ authIndex: provider.authIndex, prefix: provider.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
);
|
||||
}
|
||||
|
||||
const groups: UsageDetail[][] = [];
|
||||
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
|
||||
groups.push(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ prefix: provider.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
provider.apiKeyEntries.forEach((entry) => {
|
||||
groups.push(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return mergeUsageDetails(groups);
|
||||
};
|
||||
|
||||
export const getProviderConfigKey = (
|
||||
config: {
|
||||
authIndex?: unknown;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
proxyUrl?: string;
|
||||
},
|
||||
index: number
|
||||
): string => {
|
||||
const authIndexKey = normalizeAuthIndex(config.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${config.apiKey ?? ''}::${config.baseUrl ?? ''}::${config.proxyUrl ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => {
|
||||
const authIndexKey = normalizeAuthIndex(provider.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${provider.name}::${provider.baseUrl}::${provider.prefix ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => {
|
||||
const authIndexKey = normalizeAuthIndex(entry.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${entry.apiKey}::${entry.proxyUrl ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
apiKey: input?.apiKey ?? '',
|
||||
proxyUrl: input?.proxyUrl ?? '',
|
||||
|
||||
@@ -733,6 +733,7 @@ const renderAntigravityItems = (
|
||||
};
|
||||
|
||||
const PREMIUM_GEMINI_CLI_TIER_IDS = new Set(['g1-ultra-tier']);
|
||||
const PREMIUM_CODEX_PLAN_TYPES = new Set(['pro', 'prolite', 'pro-lite', 'pro_lite']);
|
||||
|
||||
const renderCodexItems = (
|
||||
quota: CodexQuotaState,
|
||||
@@ -748,6 +749,9 @@ const renderCodexItems = (
|
||||
const normalized = normalizePlanType(pt);
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'pro') return t('codex_quota.plan_pro');
|
||||
if (PREMIUM_CODEX_PLAN_TYPES.has(normalized) && normalized !== 'pro') {
|
||||
return t('codex_quota.plan_prolite');
|
||||
}
|
||||
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||
@@ -755,7 +759,7 @@ const renderCodexItems = (
|
||||
};
|
||||
|
||||
const planLabel = getPlanLabel(planType);
|
||||
const isPremiumPlan = normalizePlanType(planType) === 'pro';
|
||||
const isPremiumPlan = PREMIUM_CODEX_PLAN_TYPES.has(normalizePlanType(planType) ?? '');
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planLabel) {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
buildCandidateUsageSourceIds,
|
||||
formatCompactNumber,
|
||||
normalizeAuthIndex
|
||||
} from '@/utils/usage';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import { collectUsageDetails, formatCompactNumber, normalizeAuthIndex } from '@/utils/usage';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
@@ -34,11 +30,6 @@ interface CredentialRow {
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
interface CredentialBucket {
|
||||
success: number;
|
||||
failure: number;
|
||||
}
|
||||
|
||||
export function CredentialStatsCard({
|
||||
usage,
|
||||
loading,
|
||||
@@ -51,223 +42,86 @@ export function CredentialStatsCard({
|
||||
const { t } = useTranslation();
|
||||
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||
|
||||
// Fetch auth files for auth_index-based matching
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
authFilesApi
|
||||
.list()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
|
||||
if (!Array.isArray(files)) return;
|
||||
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const key = normalizeAuthIndex(rawAuthIndex);
|
||||
if (key) {
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
}
|
||||
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
|
||||
if (!key) return;
|
||||
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
});
|
||||
setAuthFileMap(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
|
||||
// Auth files are used purely for name resolution of unmatched source IDs.
|
||||
const sourceInfoMap = useMemo(
|
||||
() =>
|
||||
buildSourceInfoMap({
|
||||
geminiApiKeys: geminiKeys,
|
||||
claudeApiKeys: claudeConfigs,
|
||||
codexApiKeys: codexConfigs,
|
||||
vertexApiKeys: vertexConfigs,
|
||||
openaiCompatibility: openaiProviders,
|
||||
}),
|
||||
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
|
||||
);
|
||||
|
||||
const rows = useMemo((): CredentialRow[] => {
|
||||
if (!usage) return [];
|
||||
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;
|
||||
const rowMap = new Map<string, CredentialRow>();
|
||||
|
||||
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;
|
||||
}
|
||||
collectUsageDetails(usage).forEach((detail) => {
|
||||
const sourceInfo = resolveSourceDisplay(
|
||||
detail.source ?? '',
|
||||
detail.auth_index,
|
||||
sourceInfoMap,
|
||||
authFileMap
|
||||
);
|
||||
const key = sourceInfo.identityKey ?? sourceInfo.displayName;
|
||||
const row =
|
||||
rowMap.get(key) ??
|
||||
({
|
||||
key,
|
||||
displayName: sourceInfo.displayName,
|
||||
type: sourceInfo.type,
|
||||
success: 0,
|
||||
failure: 0,
|
||||
total: 0,
|
||||
successRate: 100,
|
||||
} satisfies CredentialRow);
|
||||
|
||||
const bucket = bySource[source] ?? { success: 0, failure: 0 };
|
||||
if (isFailed) {
|
||||
bucket.failure += 1;
|
||||
if (detail.failed === true) {
|
||||
row.failure += 1;
|
||||
} else {
|
||||
bucket.success += 1;
|
||||
row.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);
|
||||
}
|
||||
row.total = row.success + row.failure;
|
||||
row.successRate = row.total > 0 ? (row.success / row.total) * 100 : 100;
|
||||
rowMap.set(key, row);
|
||||
});
|
||||
|
||||
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
|
||||
const target = result[index];
|
||||
if (!target) return;
|
||||
target.success += bucket.success;
|
||||
target.failure += bucket.failure;
|
||||
target.total = target.success + target.failure;
|
||||
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
|
||||
};
|
||||
|
||||
// Aggregate all candidate source IDs for one provider config into a single row
|
||||
const addConfigRow = (
|
||||
apiKey: string,
|
||||
prefix: string | undefined,
|
||||
name: string,
|
||||
type: string,
|
||||
rowKey: string,
|
||||
) => {
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: rowKey,
|
||||
displayName: name,
|
||||
type,
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Provider rows — one row per config, stats merged across all its candidate source IDs
|
||||
geminiKeys.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
|
||||
claudeConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
|
||||
codexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
|
||||
vertexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
|
||||
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
|
||||
openaiProviders.forEach((provider, providerIndex) => {
|
||||
const prefix = provider.prefix;
|
||||
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: `openai:${providerIndex}`,
|
||||
displayName,
|
||||
type: 'openai',
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remaining unmatched bySource entries — resolve name from auth files if possible
|
||||
Object.entries(bySource).forEach(([key, bucket]) => {
|
||||
if (consumedSourceIds.has(key)) return;
|
||||
const total = bucket.success + bucket.failure;
|
||||
const authFile = sourceToAuthFile.get(key);
|
||||
const row = {
|
||||
key,
|
||||
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
|
||||
type: authFile?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
|
||||
};
|
||||
const rowIndex = result.push(row) - 1;
|
||||
const authIdx = sourceToAuthIndex.get(key);
|
||||
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Include requests that have auth_index but missing source.
|
||||
fallbackByAuthIndex.forEach((bucket, authIdx) => {
|
||||
if (bucket.success + bucket.failure === 0) return;
|
||||
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
let targetRowIndex = authIndexToRowIndex.get(authIdx);
|
||||
if (targetRowIndex === undefined && mapped) {
|
||||
const matchedIndex = result.findIndex(
|
||||
(row) => row.displayName === mapped.name && row.type === mapped.type
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
targetRowIndex = matchedIndex;
|
||||
authIndexToRowIndex.set(authIdx, matchedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRowIndex !== undefined) {
|
||||
mergeBucketToRow(targetRowIndex, bucket);
|
||||
return;
|
||||
}
|
||||
|
||||
const total = bucket.success + bucket.failure;
|
||||
const rowIndex = result.push({
|
||||
key: `auth:${authIdx}`,
|
||||
displayName: mapped?.name || authIdx,
|
||||
type: mapped?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: (bucket.success / total) * 100
|
||||
}) - 1;
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
});
|
||||
|
||||
return result.sort((a, b) => b.total - a.total);
|
||||
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
|
||||
return Array.from(rowMap.values()).sort((a, b) => b.total - a.total);
|
||||
}, [authFileMap, sourceInfoMap, usage]);
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
|
||||
@@ -275,51 +129,55 @@ export function CredentialStatsCard({
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : rows.length > 0 ? (
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.credential_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.success_rate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.modelCell}>
|
||||
<span>{row.displayName}</span>
|
||||
{row.type && (
|
||||
<span className={styles.credentialType}>{row.type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{formatCompactNumber(row.total)}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: row.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{row.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.credential_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.success_rate')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.modelCell}>
|
||||
<span>{row.displayName}</span>
|
||||
{row.type && <span className={styles.credentialType}>{row.type}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{formatCompactNumber(row.total)}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(
|
||||
<span className={styles.statSuccess}>
|
||||
{row.success.toLocaleString()}
|
||||
</span>{' '}
|
||||
<span className={styles.statFailure}>
|
||||
{row.failure.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: row.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{row.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatCompactNumber, formatUsd } from '@/utils/usage';
|
||||
import {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
formatCompactNumber,
|
||||
formatDurationMs,
|
||||
formatUsd,
|
||||
type ModelStatsSummary,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ModelStat {
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
export type ModelStat = ModelStatsSummary;
|
||||
|
||||
export interface ModelStatsCardProps {
|
||||
modelStats: ModelStat[];
|
||||
@@ -19,7 +18,14 @@ export interface ModelStatsCardProps {
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
|
||||
type SortKey =
|
||||
| 'model'
|
||||
| 'requests'
|
||||
| 'tokens'
|
||||
| 'cost'
|
||||
| 'successRate'
|
||||
| 'averageLatencyMs'
|
||||
| 'totalLatencyMs';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface ModelStatWithRate extends ModelStat {
|
||||
@@ -30,6 +36,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('requests');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
@@ -48,109 +58,159 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
|
||||
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
|
||||
const left = a[sortKey];
|
||||
const right = b[sortKey];
|
||||
const leftValid = typeof left === 'number' && Number.isFinite(left);
|
||||
const rightValid = typeof right === 'number' && Number.isFinite(right);
|
||||
|
||||
if (!leftValid && !rightValid) return 0;
|
||||
if (!leftValid) return 1;
|
||||
if (!rightValid) return -1;
|
||||
return dir * (left - right);
|
||||
});
|
||||
return list;
|
||||
}, [modelStats, sortKey, sortDir]);
|
||||
|
||||
const arrow = (key: SortKey) =>
|
||||
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
const arrow = (key: SortKey) => (sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '');
|
||||
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
|
||||
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||
const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0);
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : sorted.length > 0 ? (
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.model_name')}{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{t('usage_stats.requests_count')}{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<>
|
||||
{hasLatencyData && <div className={styles.detailsNote}>{latencyHint}</div>}
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}{arrow('cost')}
|
||||
{t('usage_stats.model_name')}
|
||||
{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
{t('usage_stats.requests_count')}
|
||||
{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}
|
||||
{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('averageLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('averageLatencyMs')}
|
||||
title={latencyHint}
|
||||
>
|
||||
{t('usage_stats.avg_time')}
|
||||
{arrow('averageLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('totalLatencyMs')}
|
||||
title={latencyHint}
|
||||
>
|
||||
{t('usage_stats.total_time')}
|
||||
{arrow('totalLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}
|
||||
{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}
|
||||
{arrow('cost')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(
|
||||
<span className={styles.statSuccess}>
|
||||
{stat.successCount.toLocaleString()}
|
||||
</span>{' '}
|
||||
<span className={styles.statFailure}>
|
||||
{stat.failureCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.averageLatencyMs)}
|
||||
</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.totalLatencyMs)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
|
||||
@@ -9,10 +9,14 @@ import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import { parseTimestampMs } from '@/utils/timestamp';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
extractLatencyMs,
|
||||
extractTotalTokens,
|
||||
normalizeAuthIndex
|
||||
formatDurationMs,
|
||||
LATENCY_SOURCE_FIELD,
|
||||
normalizeAuthIndex,
|
||||
} from '@/utils/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
@@ -26,11 +30,13 @@ type RequestEventRow = {
|
||||
timestampMs: number;
|
||||
timestampLabel: string;
|
||||
model: string;
|
||||
sourceKey: string;
|
||||
sourceRaw: string;
|
||||
source: string;
|
||||
sourceType: string;
|
||||
authIndex: string;
|
||||
failed: boolean;
|
||||
latencyMs: number | null;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
@@ -68,9 +74,13 @@ export function RequestEventsDetailsCard({
|
||||
claudeConfigs,
|
||||
codexConfigs,
|
||||
vertexConfigs,
|
||||
openaiProviders
|
||||
openaiProviders,
|
||||
}: RequestEventsDetailsCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
|
||||
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
|
||||
@@ -91,7 +101,7 @@ export function RequestEventsDetailsCard({
|
||||
if (!key) return;
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString()
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
});
|
||||
setAuthFileMap(map);
|
||||
@@ -117,13 +127,13 @@ export function RequestEventsDetailsCard({
|
||||
const rows = useMemo<RequestEventRow[]>(() => {
|
||||
const details = collectUsageDetails(usage);
|
||||
|
||||
return details
|
||||
const baseRows = details
|
||||
.map((detail, index) => {
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: Date.parse(timestamp);
|
||||
: parseTimestampMs(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
@@ -131,8 +141,14 @@ export function RequestEventsDetailsCard({
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
|
||||
const sourceInfo = resolveSourceDisplay(
|
||||
sourceRaw,
|
||||
authIndexRaw,
|
||||
sourceInfoMap,
|
||||
authFileMap
|
||||
);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
@@ -146,57 +162,102 @@ export function RequestEventsDetailsCard({
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
|
||||
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceKey,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens
|
||||
totalTokens,
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
const sourceLabelKeyMap = new Map<string, Set<string>>();
|
||||
baseRows.forEach((row) => {
|
||||
const keys = sourceLabelKeyMap.get(row.source) ?? new Set<string>();
|
||||
keys.add(row.sourceKey);
|
||||
sourceLabelKeyMap.set(row.source, keys);
|
||||
});
|
||||
|
||||
const buildDisambiguatedSourceLabel = (row: RequestEventRow) => {
|
||||
const labelKeyCount = sourceLabelKeyMap.get(row.source)?.size ?? 0;
|
||||
if (labelKeyCount <= 1) {
|
||||
return row.source;
|
||||
}
|
||||
|
||||
if (row.authIndex !== '-') {
|
||||
return `${row.source} · ${row.authIndex}`;
|
||||
}
|
||||
|
||||
if (row.sourceRaw !== '-' && row.sourceRaw !== row.source) {
|
||||
return `${row.source} · ${row.sourceRaw}`;
|
||||
}
|
||||
|
||||
if (row.sourceType) {
|
||||
return `${row.source} · ${row.sourceType}`;
|
||||
}
|
||||
|
||||
return `${row.source} · ${row.sourceKey}`;
|
||||
};
|
||||
|
||||
return baseRows
|
||||
.map((row) => ({
|
||||
...row,
|
||||
source: buildDisambiguatedSourceLabel(row),
|
||||
}))
|
||||
.sort((a, b) => b.timestampMs - a.timestampMs);
|
||||
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
|
||||
|
||||
const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]);
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() => [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
|
||||
value: model,
|
||||
label: model
|
||||
}))
|
||||
label: model,
|
||||
})),
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
|
||||
const sourceOptions = useMemo(
|
||||
() => [
|
||||
const sourceOptions = useMemo(() => {
|
||||
const optionMap = new Map<string, string>();
|
||||
rows.forEach((row) => {
|
||||
if (!optionMap.has(row.sourceKey)) {
|
||||
optionMap.set(row.sourceKey, row.source);
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
|
||||
value: source,
|
||||
label: source
|
||||
}))
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
...Array.from(optionMap.entries()).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
];
|
||||
}, [rows, t]);
|
||||
|
||||
const authIndexOptions = useMemo(
|
||||
() => [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
|
||||
value: authIndex,
|
||||
label: authIndex
|
||||
}))
|
||||
label: authIndex,
|
||||
})),
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
@@ -223,8 +284,10 @@ export function RequestEventsDetailsCard({
|
||||
const filteredRows = useMemo(
|
||||
() =>
|
||||
rows.filter((row) => {
|
||||
const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
|
||||
const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
|
||||
const modelMatched =
|
||||
effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
|
||||
const sourceMatched =
|
||||
effectiveSourceFilter === ALL_FILTER || row.sourceKey === effectiveSourceFilter;
|
||||
const authIndexMatched =
|
||||
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
|
||||
return modelMatched && sourceMatched && authIndexMatched;
|
||||
@@ -232,10 +295,7 @@ export function RequestEventsDetailsCard({
|
||||
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
|
||||
);
|
||||
|
||||
const renderedRows = useMemo(
|
||||
() => filteredRows.slice(0, MAX_RENDERED_EVENTS),
|
||||
[filteredRows]
|
||||
);
|
||||
const renderedRows = useMemo(() => filteredRows.slice(0, MAX_RENDERED_EVENTS), [filteredRows]);
|
||||
|
||||
const hasActiveFilters =
|
||||
effectiveModelFilter !== ALL_FILTER ||
|
||||
@@ -258,11 +318,12 @@ export function RequestEventsDetailsCard({
|
||||
'source_raw',
|
||||
'auth_index',
|
||||
'result',
|
||||
...(hasLatencyData ? ['latency_ms'] : []),
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'reasoning_tokens',
|
||||
'cached_tokens',
|
||||
'total_tokens'
|
||||
'total_tokens',
|
||||
];
|
||||
|
||||
const csvRows = filteredRows.map((row) =>
|
||||
@@ -273,11 +334,12 @@ export function RequestEventsDetailsCard({
|
||||
row.sourceRaw,
|
||||
row.authIndex,
|
||||
row.failed ? 'failed' : 'success',
|
||||
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
|
||||
row.inputTokens,
|
||||
row.outputTokens,
|
||||
row.reasoningTokens,
|
||||
row.cachedTokens,
|
||||
row.totalTokens
|
||||
row.totalTokens,
|
||||
]
|
||||
.map((value) => encodeCsv(value))
|
||||
.join(',')
|
||||
@@ -287,7 +349,7 @@ export function RequestEventsDetailsCard({
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadBlob({
|
||||
filename: `usage-events-${fileTime}.csv`,
|
||||
blob: new Blob([content], { type: 'text/csv;charset=utf-8' })
|
||||
blob: new Blob([content], { type: 'text/csv;charset=utf-8' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -301,20 +363,21 @@ export function RequestEventsDetailsCard({
|
||||
source_raw: row.sourceRaw,
|
||||
auth_index: row.authIndex,
|
||||
failed: row.failed,
|
||||
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
|
||||
tokens: {
|
||||
input_tokens: row.inputTokens,
|
||||
output_tokens: row.outputTokens,
|
||||
reasoning_tokens: row.reasoningTokens,
|
||||
cached_tokens: row.cachedTokens,
|
||||
total_tokens: row.totalTokens
|
||||
}
|
||||
total_tokens: row.totalTokens,
|
||||
},
|
||||
}));
|
||||
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
downloadBlob({
|
||||
filename: `usage-events-${fileTime}.json`,
|
||||
blob: new Blob([content], { type: 'application/json;charset=utf-8' })
|
||||
blob: new Blob([content], { type: 'application/json;charset=utf-8' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -408,11 +471,12 @@ export function RequestEventsDetailsCard({
|
||||
<>
|
||||
<div className={styles.requestEventsMeta}>
|
||||
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
|
||||
{hasLatencyData && <span className={styles.requestEventsLimitHint}>{latencyHint}</span>}
|
||||
{filteredRows.length > MAX_RENDERED_EVENTS && (
|
||||
<span className={styles.requestEventsLimitHint}>
|
||||
{t('usage_stats.request_events_limit_hint', {
|
||||
shown: MAX_RENDERED_EVENTS,
|
||||
total: filteredRows.length
|
||||
total: filteredRows.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -427,6 +491,7 @@ export function RequestEventsDetailsCard({
|
||||
<th>{t('usage_stats.request_events_source')}</th>
|
||||
<th>{t('usage_stats.request_events_auth_index')}</th>
|
||||
<th>{t('usage_stats.request_events_result')}</th>
|
||||
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
|
||||
<th>{t('usage_stats.input_tokens')}</th>
|
||||
<th>{t('usage_stats.output_tokens')}</th>
|
||||
<th>{t('usage_stats.reasoning_tokens')}</th>
|
||||
@@ -452,11 +517,18 @@ export function RequestEventsDetailsCard({
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
|
||||
className={
|
||||
row.failed
|
||||
? styles.requestEventsResultFailed
|
||||
: styles.requestEventsResultSuccess
|
||||
}
|
||||
>
|
||||
{row.failed ? t('stats.failure') : t('stats.success')}
|
||||
</span>
|
||||
</td>
|
||||
{hasLatencyData && (
|
||||
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
|
||||
)}
|
||||
<td>{row.inputTokens.toLocaleString()}</td>
|
||||
<td>{row.outputTokens.toLocaleString()}</td>
|
||||
<td>{row.reasoningTokens.toLocaleString()}</td>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
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';
|
||||
import {
|
||||
IconDiamond,
|
||||
IconDollarSign,
|
||||
IconSatellite,
|
||||
IconTimer,
|
||||
IconTrendingUp,
|
||||
} from '@/components/ui/icons';
|
||||
import {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
calculateLatencyStatsFromDetails,
|
||||
calculateCost,
|
||||
formatCompactNumber,
|
||||
formatDurationMs,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateCost,
|
||||
collectUsageDetails,
|
||||
extractTotalTokens,
|
||||
type ModelPrice
|
||||
type ModelPrice,
|
||||
} from '@/utils/usage';
|
||||
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
@@ -44,20 +53,31 @@ export interface StatCardsProps {
|
||||
|
||||
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
const latencyHint = t('usage_stats.latency_unit_hint', {
|
||||
field: LATENCY_SOURCE_FIELD,
|
||||
unit: t('usage_stats.duration_unit_ms'),
|
||||
});
|
||||
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
const { tokenBreakdown, rateStats, totalCost } = useMemo(() => {
|
||||
const { tokenBreakdown, rateStats, totalCost, latencyStats } = useMemo(() => {
|
||||
const empty = {
|
||||
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
|
||||
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
|
||||
totalCost: 0
|
||||
totalCost: 0,
|
||||
latencyStats: {
|
||||
averageMs: null as number | null,
|
||||
totalMs: null as number | null,
|
||||
sampleCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
if (!usage) return empty;
|
||||
const details = collectUsageDetails(usage);
|
||||
if (!details.length) return empty;
|
||||
|
||||
const latencyStats = calculateLatencyStatsFromDetails(details);
|
||||
|
||||
let cachedTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
let totalCost = 0;
|
||||
@@ -80,7 +100,12 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
}
|
||||
|
||||
const timestamp = detail.__timestampMs ?? 0;
|
||||
if (hasValidNow && Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= now) {
|
||||
if (
|
||||
hasValidNow &&
|
||||
Number.isFinite(timestamp) &&
|
||||
timestamp >= windowStart &&
|
||||
timestamp <= now
|
||||
) {
|
||||
requestCount += 1;
|
||||
tokenCount += extractTotalTokens(detail);
|
||||
}
|
||||
@@ -98,9 +123,10 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
tpm: tokenCount / denominator,
|
||||
windowMinutes,
|
||||
requestCount,
|
||||
tokenCount
|
||||
tokenCount,
|
||||
},
|
||||
totalCost
|
||||
totalCost,
|
||||
latencyStats,
|
||||
};
|
||||
}, [hasPrices, modelPrices, nowMs, usage]);
|
||||
|
||||
@@ -123,9 +149,15 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
|
||||
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||
</span>
|
||||
{latencyStats.sampleCount > 0 && (
|
||||
<span className={styles.statMetaItem} title={latencyHint}>
|
||||
{t('usage_stats.avg_time')}:{' '}
|
||||
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
trend: sparklines.requests
|
||||
trend: sparklines.requests,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
@@ -138,14 +170,16 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||
{t('usage_stats.cached_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||
{t('usage_stats.reasoning_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
trend: sparklines.tokens
|
||||
trend: sparklines.tokens,
|
||||
},
|
||||
{
|
||||
key: 'rpm',
|
||||
@@ -157,10 +191,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||
{t('usage_stats.total_requests')}:{' '}
|
||||
{loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.rpm
|
||||
trend: sparklines.rpm,
|
||||
},
|
||||
{
|
||||
key: 'tpm',
|
||||
@@ -172,10 +207,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||
{t('usage_stats.total_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.tpm
|
||||
trend: sparklines.tpm,
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
@@ -188,7 +224,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
|
||||
{t('usage_stats.total_tokens')}:{' '}
|
||||
{loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
|
||||
</span>
|
||||
{!hasPrices && (
|
||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||
@@ -197,8 +234,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
)}
|
||||
</>
|
||||
),
|
||||
trend: hasPrices ? sparklines.cost : null
|
||||
}
|
||||
trend: hasPrices ? sparklines.cost : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -211,7 +248,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
{
|
||||
'--accent': card.accent,
|
||||
'--accent-soft': card.accentSoft,
|
||||
'--accent-border': card.accentBorder
|
||||
'--accent-border': card.accentBorder,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -225,7 +262,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
|
||||
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
|
||||
<div className={styles.statTrend}>
|
||||
{card.trend ? (
|
||||
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
|
||||
<Line
|
||||
className={styles.sparkline}
|
||||
data={card.trend.data}
|
||||
options={sparklineOptions}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.statTrendPlaceholder}></div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { parseTimestamp } from '@/utils/timestamp';
|
||||
import {
|
||||
normalizeAuthIndex,
|
||||
normalizeUsageSourceId,
|
||||
@@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => {
|
||||
const date =
|
||||
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
||||
: new Date(String(raw));
|
||||
: parseTimestamp(raw) ?? new Date(String(raw));
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
||||
};
|
||||
|
||||
|
||||
@@ -647,6 +647,12 @@ function getNextDirtyFields(
|
||||
nextValues.quotaSwitchPreviewModel === baselineValues.quotaSwitchPreviewModel
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'quotaAntigravityCredits')) {
|
||||
updateDirty(
|
||||
'quotaAntigravityCredits',
|
||||
nextValues.quotaAntigravityCredits === baselineValues.quotaAntigravityCredits
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
|
||||
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
|
||||
}
|
||||
@@ -827,6 +833,7 @@ export function useVisualConfig() {
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? true),
|
||||
|
||||
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
|
||||
@@ -929,11 +936,16 @@ export function useVisualConfig() {
|
||||
if (
|
||||
docHas(doc, ['quota-exceeded']) ||
|
||||
!values.quotaSwitchProject ||
|
||||
!values.quotaSwitchPreviewModel
|
||||
!values.quotaSwitchPreviewModel ||
|
||||
!values.quotaAntigravityCredits
|
||||
) {
|
||||
ensureMapInDoc(doc, ['quota-exceeded']);
|
||||
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
|
||||
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
deleteIfMapEmpty(doc, ['quota-exceeded']);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
import en from './locales/en.json';
|
||||
import ru from './locales/ru.json';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
@@ -12,6 +13,7 @@ import { getInitialLanguage } from '@/utils/language';
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'zh-TW': { translation: zhTW },
|
||||
en: { translation: en },
|
||||
ru: { translation: ru }
|
||||
},
|
||||
|
||||
+15
-30
@@ -670,7 +670,8 @@
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
"plan_pro": "Pro 20x",
|
||||
"plan_prolite": "Pro 5x"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI Quota",
|
||||
@@ -890,17 +891,6 @@
|
||||
"kimi_oauth_status_error": "Authentication failed:",
|
||||
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
|
||||
"kimi_oauth_polling_error": "Failed to check authentication status:",
|
||||
"qwen_oauth_title": "Qwen OAuth",
|
||||
"qwen_oauth_button": "Start Qwen Login",
|
||||
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
|
||||
"qwen_oauth_url_label": "Authorization URL:",
|
||||
"qwen_open_link": "Open Link",
|
||||
"qwen_copy_link": "Copy Link",
|
||||
"qwen_oauth_status_waiting": "Waiting for authentication...",
|
||||
"qwen_oauth_status_success": "Authentication successful!",
|
||||
"qwen_oauth_status_error": "Authentication failed:",
|
||||
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
|
||||
"qwen_oauth_polling_error": "Failed to check authentication status:",
|
||||
"oauth_callback_label": "Callback URL",
|
||||
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
|
||||
"oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.",
|
||||
@@ -923,23 +913,6 @@
|
||||
"iflow_oauth_status_error": "Authentication failed:",
|
||||
"iflow_oauth_start_error": "Failed to start iFlow OAuth:",
|
||||
"iflow_oauth_polling_error": "Failed to check authentication status:",
|
||||
"iflow_cookie_title": "iFlow Cookie Login",
|
||||
"iflow_cookie_label": "Cookie Value:",
|
||||
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
|
||||
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
|
||||
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
|
||||
"iflow_cookie_button": "Submit Cookie Login",
|
||||
"iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.",
|
||||
"iflow_cookie_status_error": "Cookie login failed:",
|
||||
"iflow_cookie_status_duplicate": "Duplicate config:",
|
||||
"iflow_cookie_start_error": "Failed to submit cookie login:",
|
||||
"iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.",
|
||||
"iflow_cookie_required": "Please provide the Cookie value first.",
|
||||
"iflow_cookie_result_title": "Cookie Login Result",
|
||||
"iflow_cookie_result_email": "Account",
|
||||
"iflow_cookie_result_expired": "Expires At",
|
||||
"iflow_cookie_result_path": "Saved Path",
|
||||
"iflow_cookie_result_type": "Type",
|
||||
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost."
|
||||
},
|
||||
"usage_stats": {
|
||||
@@ -1029,6 +1002,15 @@
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"time": "Latency",
|
||||
"avg_time": "Avg Latency",
|
||||
"total_time": "Total Latency",
|
||||
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
|
||||
"duration_unit_d": "d",
|
||||
"duration_unit_h": "h",
|
||||
"duration_unit_m": "m",
|
||||
"duration_unit_s": "s",
|
||||
"duration_unit_ms": "ms",
|
||||
"request_events_empty_title": "No request events",
|
||||
"request_events_empty_desc": "No request details are available for the selected time range.",
|
||||
"request_events_no_result_title": "No matching events",
|
||||
@@ -1260,7 +1242,9 @@
|
||||
"switch_project": "Switch Project",
|
||||
"switch_project_desc": "Automatically switch to another project when quota is exceeded",
|
||||
"switch_preview_model": "Switch to Preview Model",
|
||||
"switch_preview_model_desc": "Switch to preview model version when quota is exceeded"
|
||||
"switch_preview_model_desc": "Switch to preview model version when quota is exceeded",
|
||||
"antigravity_credits": "Antigravity Credits Retry",
|
||||
"antigravity_credits_desc": "Retry once with enabledCreditTypes=[\"GOOGLE_ONE_AI\"] when Antigravity returns quota_exhausted 429"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "Streaming Configuration",
|
||||
@@ -1475,6 +1459,7 @@
|
||||
"language": {
|
||||
"switch": "Language",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "Traditional Chinese (Taiwan)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
+15
-30
@@ -667,7 +667,8 @@
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
"plan_pro": "Pro 20x",
|
||||
"plan_prolite": "Pro 5x"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Квота Gemini CLI",
|
||||
@@ -887,17 +888,6 @@
|
||||
"kimi_oauth_status_error": "Ошибка аутентификации:",
|
||||
"kimi_oauth_start_error": "Не удалось запустить Kimi OAuth:",
|
||||
"kimi_oauth_polling_error": "Не удалось проверить статус аутентификации:",
|
||||
"qwen_oauth_title": "Qwen OAuth",
|
||||
"qwen_oauth_button": "Начать вход Qwen",
|
||||
"qwen_oauth_hint": "Выполните вход в сервис Qwen через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.",
|
||||
"qwen_oauth_url_label": "URL авторизации:",
|
||||
"qwen_open_link": "Открыть ссылку",
|
||||
"qwen_copy_link": "Скопировать ссылку",
|
||||
"qwen_oauth_status_waiting": "Ожидание аутентификации...",
|
||||
"qwen_oauth_status_success": "Аутентификация успешна!",
|
||||
"qwen_oauth_status_error": "Ошибка аутентификации:",
|
||||
"qwen_oauth_start_error": "Не удалось запустить Qwen OAuth:",
|
||||
"qwen_oauth_polling_error": "Не удалось проверить статус аутентификации:",
|
||||
"oauth_callback_label": "Callback URL",
|
||||
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
|
||||
"oauth_callback_hint": "Режим удалённого браузера: после перенаправления провайдера на http://localhost:... скопируйте полный URL и отправьте его здесь.",
|
||||
@@ -920,23 +910,6 @@
|
||||
"iflow_oauth_status_error": "Ошибка аутентификации:",
|
||||
"iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:",
|
||||
"iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:",
|
||||
"iflow_cookie_title": "Вход iFlow по cookie",
|
||||
"iflow_cookie_label": "Значение cookie:",
|
||||
"iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=",
|
||||
"iflow_cookie_hint": "Отправьте существующий cookie, чтобы завершить вход без открытия ссылки авторизации; файл учётных данных будет сохранён автоматически.",
|
||||
"iflow_cookie_key_hint": "Примечание: сначала создайте ключ на платформе.",
|
||||
"iflow_cookie_button": "Отправить вход по cookie",
|
||||
"iflow_cookie_status_success": "Вход по cookie выполнен, учётные данные сохранены.",
|
||||
"iflow_cookie_status_error": "Ошибка входа по cookie:",
|
||||
"iflow_cookie_status_duplicate": "Дублирующая конфигурация:",
|
||||
"iflow_cookie_start_error": "Не удалось отправить вход по cookie:",
|
||||
"iflow_cookie_config_duplicate": "Такая конфигурация уже существует. Удалите файл и повторите, если хотите перезаписать.",
|
||||
"iflow_cookie_required": "Сначала укажите значение cookie.",
|
||||
"iflow_cookie_result_title": "Результат входа по cookie",
|
||||
"iflow_cookie_result_email": "Аккаунт",
|
||||
"iflow_cookie_result_expired": "Истекает",
|
||||
"iflow_cookie_result_path": "Путь сохранения",
|
||||
"iflow_cookie_result_type": "Тип",
|
||||
"remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost."
|
||||
},
|
||||
"usage_stats": {
|
||||
@@ -1026,6 +999,15 @@
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"time": "Задержка",
|
||||
"avg_time": "Средняя задержка",
|
||||
"total_time": "Суммарная задержка",
|
||||
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
|
||||
"duration_unit_d": "д",
|
||||
"duration_unit_h": "ч",
|
||||
"duration_unit_m": "мин",
|
||||
"duration_unit_s": "с",
|
||||
"duration_unit_ms": "мс",
|
||||
"request_events_empty_title": "События запросов отсутствуют",
|
||||
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
|
||||
"request_events_no_result_title": "Совпадений не найдено",
|
||||
@@ -1259,7 +1241,9 @@
|
||||
"switch_project": "Переключить проект",
|
||||
"switch_project_desc": "Автоматически переходить на другой проект при превышении квоты",
|
||||
"switch_preview_model": "Переключить на preview-модель",
|
||||
"switch_preview_model_desc": "Переключаться на preview-версию модели при превышении квоты"
|
||||
"switch_preview_model_desc": "Переключаться на preview-версию модели при превышении квоты",
|
||||
"antigravity_credits": "Повтор Antigravity Credits",
|
||||
"antigravity_credits_desc": "При ответе Antigravity quota_exhausted 429 повторять запрос один раз с enabledCreditTypes=[\"GOOGLE_ONE_AI\"]"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "Настройки стриминга",
|
||||
@@ -1474,6 +1458,7 @@
|
||||
"language": {
|
||||
"switch": "Язык",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "繁體中文(台灣)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
+15
-30
@@ -670,7 +670,8 @@
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro"
|
||||
"plan_pro": "Pro 20x",
|
||||
"plan_prolite": "Pro 5x"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI 额度",
|
||||
@@ -890,17 +891,6 @@
|
||||
"kimi_oauth_status_error": "认证失败:",
|
||||
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
|
||||
"kimi_oauth_polling_error": "检查认证状态失败:",
|
||||
"qwen_oauth_title": "Qwen OAuth",
|
||||
"qwen_oauth_button": "开始 Qwen 登录",
|
||||
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
|
||||
"qwen_oauth_url_label": "授权链接:",
|
||||
"qwen_open_link": "打开链接",
|
||||
"qwen_copy_link": "复制链接",
|
||||
"qwen_oauth_status_waiting": "等待认证中...",
|
||||
"qwen_oauth_status_success": "认证成功!",
|
||||
"qwen_oauth_status_error": "认证失败:",
|
||||
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
|
||||
"qwen_oauth_polling_error": "检查认证状态失败:",
|
||||
"oauth_callback_label": "回调 URL",
|
||||
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
|
||||
"oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。",
|
||||
@@ -923,23 +913,6 @@
|
||||
"iflow_oauth_status_error": "认证失败:",
|
||||
"iflow_oauth_start_error": "启动 iFlow OAuth 失败:",
|
||||
"iflow_oauth_polling_error": "检查认证状态失败:",
|
||||
"iflow_cookie_title": "iFlow Cookie 登录",
|
||||
"iflow_cookie_label": "Cookie 内容:",
|
||||
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
|
||||
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
|
||||
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
|
||||
"iflow_cookie_button": "提交 Cookie 登录",
|
||||
"iflow_cookie_status_success": "Cookie 登录成功,凭据已保存。",
|
||||
"iflow_cookie_status_error": "Cookie 登录失败:",
|
||||
"iflow_cookie_status_duplicate": "配置文件重复:",
|
||||
"iflow_cookie_start_error": "提交 Cookie 登录失败:",
|
||||
"iflow_cookie_config_duplicate": "检测到配置文件已存在(重复),如需重新保存请先删除原文件后重试。",
|
||||
"iflow_cookie_required": "请先填写 Cookie 内容",
|
||||
"iflow_cookie_result_title": "Cookie 登录结果",
|
||||
"iflow_cookie_result_email": "账号",
|
||||
"iflow_cookie_result_expired": "过期时间",
|
||||
"iflow_cookie_result_path": "保存路径",
|
||||
"iflow_cookie_result_type": "类型",
|
||||
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问"
|
||||
},
|
||||
"usage_stats": {
|
||||
@@ -1029,6 +1002,15 @@
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"time": "延迟",
|
||||
"avg_time": "平均延迟",
|
||||
"total_time": "总延迟",
|
||||
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "时",
|
||||
"duration_unit_m": "分",
|
||||
"duration_unit_s": "秒",
|
||||
"duration_unit_ms": "毫秒",
|
||||
"request_events_empty_title": "暂无请求事件",
|
||||
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
|
||||
"request_events_no_result_title": "没有匹配结果",
|
||||
@@ -1260,7 +1242,9 @@
|
||||
"switch_project": "切换项目",
|
||||
"switch_project_desc": "配额耗尽时自动切换到其他项目",
|
||||
"switch_preview_model": "切换预览模型",
|
||||
"switch_preview_model_desc": "配额耗尽时切换到预览版本模型"
|
||||
"switch_preview_model_desc": "配额耗尽时切换到预览版本模型",
|
||||
"antigravity_credits": "Antigravity Credits 重试",
|
||||
"antigravity_credits_desc": "Antigravity 返回 quota_exhausted 429 时,使用 enabledCreditTypes=[\"GOOGLE_ONE_AI\"] 重试一次"
|
||||
},
|
||||
"streaming": {
|
||||
"title": "流式传输配置",
|
||||
@@ -1475,6 +1459,7 @@
|
||||
"language": {
|
||||
"switch": "语言",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "繁體中文(台灣)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { ampcodeApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
export function AiProvidersPage() {
|
||||
@@ -71,6 +71,10 @@ export function AiProvidersPage() {
|
||||
() => indexUsageDetailsBySource(usageDetails),
|
||||
[usageDetails]
|
||||
);
|
||||
const usageDetailsByAuthIndex = useMemo(
|
||||
() => indexUsageDetailsByAuthIndex(usageDetails),
|
||||
[usageDetails]
|
||||
);
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
@@ -378,6 +382,7 @@ export function AiProvidersPage() {
|
||||
configs={geminiKeys}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -393,6 +398,7 @@ export function AiProvidersPage() {
|
||||
configs={codexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -408,6 +414,7 @@ export function AiProvidersPage() {
|
||||
configs={claudeConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -423,6 +430,7 @@ export function AiProvidersPage() {
|
||||
configs={vertexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -448,6 +456,7 @@ export function AiProvidersPage() {
|
||||
configs={openaiProviders}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
|
||||
+2
-128
@@ -4,7 +4,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
|
||||
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import styles from './OAuthPage.module.scss';
|
||||
@@ -14,8 +14,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
|
||||
interface ProviderState {
|
||||
@@ -32,14 +30,6 @@ interface ProviderState {
|
||||
callbackError?: string;
|
||||
}
|
||||
|
||||
interface IFlowCookieState {
|
||||
cookie: string;
|
||||
loading: boolean;
|
||||
result?: IFlowCookieAuthResponse;
|
||||
error?: string;
|
||||
errorType?: 'error' | 'warning';
|
||||
}
|
||||
|
||||
interface VertexImportResult {
|
||||
projectId?: string;
|
||||
email?: string;
|
||||
@@ -76,8 +66,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
|
||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
||||
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } }
|
||||
];
|
||||
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
@@ -94,7 +83,6 @@ export function OAuthPage() {
|
||||
const { showNotification } = useNotificationStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||
fileName: '',
|
||||
location: '',
|
||||
@@ -235,49 +223,6 @@ export function OAuthPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitIflowCookie = async () => {
|
||||
const cookie = iflowCookie.cookie.trim();
|
||||
if (!cookie) {
|
||||
showNotification(t('auth_login.iflow_cookie_required'), 'warning');
|
||||
return;
|
||||
}
|
||||
setIflowCookie((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
result: undefined
|
||||
}));
|
||||
try {
|
||||
const res = await oauthApi.iflowCookieAuth(cookie);
|
||||
if (res.status === 'ok') {
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, result: res }));
|
||||
showNotification(t('auth_login.iflow_cookie_status_success'), 'success');
|
||||
} else {
|
||||
setIflowCookie((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: res.error,
|
||||
errorType: 'error'
|
||||
}));
|
||||
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (getErrorStatus(err) === 409) {
|
||||
const message = t('auth_login.iflow_cookie_config_duplicate');
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
|
||||
showNotification(message, 'warning');
|
||||
return;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
|
||||
showNotification(
|
||||
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVertexFilePick = () => {
|
||||
vertexFileInputRef.current?.click();
|
||||
};
|
||||
@@ -542,77 +487,6 @@ export function OAuthPage() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* iFlow Cookie 登录 */}
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
|
||||
{t('auth_login.iflow_cookie_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
|
||||
{t('auth_login.iflow_cookie_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHint}>{t('auth_login.iflow_cookie_hint')}</div>
|
||||
<div className={styles.cardHintSecondary}>
|
||||
{t('auth_login.iflow_cookie_key_hint')}
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formItemLabel}>{t('auth_login.iflow_cookie_label')}</label>
|
||||
<Input
|
||||
value={iflowCookie.cookie}
|
||||
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
|
||||
placeholder={t('auth_login.iflow_cookie_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{iflowCookie.error && (
|
||||
<div
|
||||
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
|
||||
>
|
||||
{iflowCookie.errorType === 'warning'
|
||||
? t('auth_login.iflow_cookie_status_duplicate')
|
||||
: t('auth_login.iflow_cookie_status_error')}{' '}
|
||||
{iflowCookie.error}
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
|
||||
<div className={styles.connectionBox}>
|
||||
<div className={styles.connectionLabel}>{t('auth_login.iflow_cookie_result_title')}</div>
|
||||
<div className={styles.keyValueList}>
|
||||
{iflowCookie.result.email && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_email')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.expired && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_expired')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.expired}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.saved_path && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_path')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.saved_path}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.type && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_type')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -128,8 +128,7 @@
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
|
||||
var(--bg-primary);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), var(--bg-primary);
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
@@ -137,7 +136,10 @@
|
||||
gap: 10px;
|
||||
min-height: 176px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
||||
transition:
|
||||
transform $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
border-color $transition-fast;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
@@ -350,7 +352,10 @@
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
@@ -505,6 +510,12 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.detailsNote {
|
||||
padding: 0 4px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Table (80%比例)
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
@@ -515,7 +526,8 @@
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@@ -596,6 +608,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.durationCell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Pricing Section (80%比例)
|
||||
.pricingSection {
|
||||
display: flex;
|
||||
@@ -1095,7 +1112,9 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
.healthBlockWrapper:hover &,
|
||||
.healthBlockWrapper.healthBlockActive & {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { parseTimestampMs } from '@/utils/timestamp';
|
||||
import {
|
||||
collectUsageDetailsWithEndpoint,
|
||||
normalizeAuthIndex,
|
||||
@@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
if (!logPath) return [];
|
||||
|
||||
const logTimestampMs = traceLogLine.timestamp
|
||||
? Date.parse(traceLogLine.timestamp)
|
||||
? parseTimestampMs(traceLogLine.timestamp)
|
||||
: Number.NaN;
|
||||
|
||||
// Step 1: filter by path match
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { apiClient } from './client';
|
||||
import type { AuthFilesResponse } from '@/types/authFile';
|
||||
import type { OAuthModelAliasEntry } from '@/types';
|
||||
import { parseTimestampMs } from '@/utils/timestamp';
|
||||
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
@@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => {
|
||||
if (Number.isFinite(asNumber)) {
|
||||
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
const parsed = parseTimestampMs(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'kimi'
|
||||
| 'qwen';
|
||||
| 'kimi';
|
||||
|
||||
export interface OAuthStartResponse {
|
||||
url: string;
|
||||
@@ -21,15 +20,6 @@ export interface OAuthCallbackResponse {
|
||||
status: 'ok';
|
||||
}
|
||||
|
||||
export interface IFlowCookieAuthResponse {
|
||||
status: 'ok' | 'error';
|
||||
error?: string;
|
||||
saved_path?: string;
|
||||
email?: string;
|
||||
expired?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
|
||||
'gemini-cli': 'gemini'
|
||||
@@ -60,9 +50,5 @@ export const oauthApi = {
|
||||
provider: callbackProvider,
|
||||
redirect_url: redirectUrl
|
||||
});
|
||||
},
|
||||
|
||||
/** iFlow cookie 认证 */
|
||||
iflowCookieAuth: (cookie: string) =>
|
||||
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,6 +94,12 @@ const normalizePrefix = (value: unknown): string | undefined => {
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeAuthIndex = (value: unknown): string | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
if (entry === undefined || entry === null) return null;
|
||||
const record = isRecord(entry) ? entry : null;
|
||||
@@ -104,12 +110,17 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
const headers = record ? normalizeHeaders(record.headers) : undefined;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
|
||||
return {
|
||||
const result: ApiKeyEntry = {
|
||||
apiKey: trimmed,
|
||||
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
|
||||
headers
|
||||
};
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
|
||||
@@ -146,6 +157,10 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
record?.excluded_models
|
||||
);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
|
||||
const cloakRaw = record?.cloak;
|
||||
if (isRecord(cloakRaw)) {
|
||||
@@ -204,6 +219,10 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (headers) config.headers = headers;
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -241,6 +260,10 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
|
||||
if (models.length) result.models = models;
|
||||
if (priority !== undefined) result.priority = Number(priority);
|
||||
if (testModel) result.testModel = String(testModel);
|
||||
const authIndex = normalizeAuthIndex(
|
||||
provider['auth-index'] ?? provider.authIndex ?? provider['auth_index']
|
||||
);
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -366,7 +389,12 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
if (isRecord(quota)) {
|
||||
config.quotaExceeded = {
|
||||
switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject),
|
||||
switchPreviewModel: normalizeBoolean(quota['switch-preview-model'] ?? quota.switchPreviewModel)
|
||||
switchPreviewModel: normalizeBoolean(
|
||||
quota['switch-preview-model'] ?? quota.switchPreviewModel
|
||||
),
|
||||
antigravityCredits: normalizeBoolean(
|
||||
quota['antigravity-credits'] ?? quota.antigravityCredits
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
export type Theme = 'light' | 'white' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||
export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ru';
|
||||
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { AmpcodeConfig } from './ampcode';
|
||||
export interface QuotaExceededConfig {
|
||||
switchProject?: boolean;
|
||||
switchPreviewModel?: boolean;
|
||||
antigravityCredits?: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
||||
+1
-2
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'kimi'
|
||||
| 'qwen';
|
||||
| 'kimi';
|
||||
|
||||
// OAuth 流程状态
|
||||
export interface OAuthFlow {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ApiKeyEntry {
|
||||
apiKey: string;
|
||||
proxyUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface CloakConfig {
|
||||
@@ -31,6 +32,7 @@ export interface GeminiKeyConfig {
|
||||
models?: ModelAlias[];
|
||||
headers?: Record<string, string>;
|
||||
excludedModels?: string[];
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface ProviderKeyConfig {
|
||||
@@ -44,6 +46,7 @@ export interface ProviderKeyConfig {
|
||||
models?: ModelAlias[];
|
||||
excludedModels?: string[];
|
||||
cloak?: CloakConfig;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIProviderConfig {
|
||||
@@ -55,5 +58,6 @@ export interface OpenAIProviderConfig {
|
||||
models?: ModelAlias[];
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
authIndex?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type SourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
identityKey?: string;
|
||||
};
|
||||
|
||||
export type CredentialInfo = {
|
||||
|
||||
@@ -75,6 +75,7 @@ export type VisualConfigValues = {
|
||||
maxRetryInterval: string;
|
||||
quotaSwitchProject: boolean;
|
||||
quotaSwitchPreviewModel: boolean;
|
||||
quotaAntigravityCredits: boolean;
|
||||
routingStrategy: 'round-robin' | 'fill-first';
|
||||
wsAuth: boolean;
|
||||
payloadDefaultRules: PayloadRule[];
|
||||
@@ -114,6 +115,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
maxRetryInterval: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
quotaAntigravityCredits: true,
|
||||
routingStrategy: 'round-robin',
|
||||
wsAuth: false,
|
||||
payloadDefaultRules: [],
|
||||
|
||||
@@ -40,9 +40,10 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
|
||||
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
|
||||
|
||||
// 语言配置
|
||||
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
|
||||
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'zh-TW', 'en', 'ru'] as const);
|
||||
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
|
||||
'zh-CN': 'language.chinese',
|
||||
'zh-TW': 'language.chinese_tw',
|
||||
en: 'language.english',
|
||||
ru: 'language.russian'
|
||||
};
|
||||
@@ -57,16 +58,14 @@ export const OAUTH_CARD_IDS = [
|
||||
'anthropic-oauth-card',
|
||||
'antigravity-oauth-card',
|
||||
'gemini-cli-oauth-card',
|
||||
'kimi-oauth-card',
|
||||
'qwen-oauth-card'
|
||||
'kimi-oauth-card'
|
||||
];
|
||||
export const OAUTH_PROVIDERS = {
|
||||
CODEX: 'codex',
|
||||
ANTHROPIC: 'anthropic',
|
||||
ANTIGRAVITY: 'antigravity',
|
||||
GEMINI_CLI: 'gemini-cli',
|
||||
KIMI: 'kimi',
|
||||
QWEN: 'qwen'
|
||||
KIMI: 'kimi'
|
||||
} as const;
|
||||
|
||||
// API 端点
|
||||
|
||||
+4
-2
@@ -1,3 +1,5 @@
|
||||
import { parseTimestamp } from './timestamp';
|
||||
|
||||
/**
|
||||
* 格式化工具函数
|
||||
* 从原项目 src/utils/string.js 迁移
|
||||
@@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string {
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date, locale?: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date;
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return 'Invalid Date';
|
||||
@@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
|
||||
const asNumber = typeof value === 'number' ? value : Number(value);
|
||||
const date = (() => {
|
||||
if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) {
|
||||
return new Date(String(value));
|
||||
return parseTimestamp(value) ?? new Date(String(value));
|
||||
}
|
||||
|
||||
const abs = Math.abs(asNumber);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
|
||||
|
||||
const TRADITIONAL_CHINESE_PREFIXES = ['zh-tw', 'zh-hk', 'zh-mo', 'zh-hant'] as const;
|
||||
|
||||
export const isSupportedLanguage = (value: string): value is Language =>
|
||||
SUPPORTED_LANGUAGES.includes(value as Language);
|
||||
|
||||
@@ -40,6 +42,7 @@ const getBrowserLanguage = (): Language => {
|
||||
}
|
||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||
const lower = raw.toLowerCase();
|
||||
if (TRADITIONAL_CHINESE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return 'zh-TW';
|
||||
if (lower.startsWith('zh')) return 'zh-CN';
|
||||
if (lower.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
|
||||
+109
-23
@@ -10,20 +10,64 @@ export interface SourceInfoMapInput {
|
||||
openaiCompatibility?: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, SourceInfo> {
|
||||
const map = new Map<string, SourceInfo>();
|
||||
type SourceInfoEntry = Required<Pick<SourceInfo, 'displayName' | 'type' | 'identityKey'>>;
|
||||
|
||||
const registerSource = (sourceId: string, displayName: string, type: string) => {
|
||||
if (!sourceId || !displayName || map.has(sourceId)) return;
|
||||
map.set(sourceId, { displayName, type });
|
||||
};
|
||||
export interface SourceInfoMap {
|
||||
byAuthIndex: Map<string, SourceInfoEntry | null>;
|
||||
bySource: Map<string, SourceInfoEntry | null>;
|
||||
}
|
||||
|
||||
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
|
||||
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
|
||||
const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`;
|
||||
|
||||
const registerIdentity = (
|
||||
map: Map<string, SourceInfoEntry | null>,
|
||||
key: string | null | undefined,
|
||||
entry: SourceInfoEntry
|
||||
) => {
|
||||
if (!key) return;
|
||||
|
||||
const existing = map.get(key);
|
||||
if (existing === undefined) {
|
||||
map.set(key, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.identityKey === entry.identityKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.set(key, null);
|
||||
};
|
||||
|
||||
const formatRawSourceDisplayName = (source: string) => {
|
||||
if (!source) return '-';
|
||||
return source.startsWith('t:') ? source.slice(2) : source;
|
||||
};
|
||||
|
||||
export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap {
|
||||
const byAuthIndex = new Map<string, SourceInfoEntry | null>();
|
||||
const bySource = new Map<string, SourceInfoEntry | null>();
|
||||
|
||||
const registerProvider = (
|
||||
entry: SourceInfoEntry,
|
||||
authIndices: Array<unknown>,
|
||||
candidates: Iterable<string>
|
||||
) => {
|
||||
authIndices.forEach((authIndex) => {
|
||||
registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry);
|
||||
});
|
||||
|
||||
Array.from(candidates).forEach((candidate) => {
|
||||
registerIdentity(bySource, candidate, entry);
|
||||
});
|
||||
};
|
||||
|
||||
const providers: Array<{
|
||||
items: Array<{ apiKey?: string; prefix?: string }>;
|
||||
items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>;
|
||||
type: string;
|
||||
label: string;
|
||||
}> = [
|
||||
@@ -35,49 +79,91 @@ export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, Sourc
|
||||
|
||||
providers.forEach(({ items, type, label }) => {
|
||||
items.forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `${label} #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
type,
|
||||
registerProvider(
|
||||
{
|
||||
displayName: item.prefix?.trim() || `${label} #${index + 1}`,
|
||||
type,
|
||||
identityKey: buildProviderIdentityKey(type, index),
|
||||
},
|
||||
[item.authIndex],
|
||||
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>();
|
||||
const authIndices: Array<unknown> = [provider.authIndex];
|
||||
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
authIndices.push(entry.authIndex);
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
registerCandidates(displayName, 'openai', Array.from(candidates));
|
||||
|
||||
registerProvider(
|
||||
{
|
||||
displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`,
|
||||
type: 'openai',
|
||||
identityKey: buildProviderIdentityKey('openai', providerIndex),
|
||||
},
|
||||
authIndices,
|
||||
candidates
|
||||
);
|
||||
});
|
||||
|
||||
return map;
|
||||
return { byAuthIndex, bySource };
|
||||
}
|
||||
|
||||
export function resolveSourceDisplay(
|
||||
sourceRaw: string,
|
||||
authIndex: unknown,
|
||||
sourceInfoMap: Map<string, SourceInfo>,
|
||||
sourceInfoMap: SourceInfoMap,
|
||||
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 matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey);
|
||||
if (matchedByAuthIndex) {
|
||||
return matchedByAuthIndex;
|
||||
}
|
||||
|
||||
const authInfo = authFileMap.get(authIndexKey);
|
||||
if (authInfo) {
|
||||
return { displayName: authInfo.name || authIndexKey, type: authInfo.type };
|
||||
return {
|
||||
displayName: authInfo.name || authIndexKey,
|
||||
type: authInfo.type,
|
||||
identityKey: `auth:${authIndexKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null;
|
||||
if (matchedBySource) {
|
||||
return matchedBySource;
|
||||
}
|
||||
|
||||
if (source) {
|
||||
return {
|
||||
displayName: formatRawSourceDisplayName(source),
|
||||
type: '',
|
||||
identityKey: `source:${source}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (authIndexKey) {
|
||||
return {
|
||||
displayName: authIndexKey,
|
||||
type: '',
|
||||
identityKey: `auth:${authIndexKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
|
||||
displayName: '-',
|
||||
type: '',
|
||||
identityKey: 'source:-',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const RFC3339_HIGH_PRECISION_REGEX =
|
||||
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i;
|
||||
|
||||
/**
|
||||
* Some browsers mis-handle RFC3339 timestamps that include sub-millisecond
|
||||
* precision. Normalize them to millisecond precision before parsing.
|
||||
*/
|
||||
export function normalizeTimestampForDateParse(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX);
|
||||
if (!match) return trimmed;
|
||||
|
||||
const [, base, , fractionDigits = '', timezone = ''] = match;
|
||||
if (fractionDigits.length <= 3) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${base}.${fractionDigits.slice(0, 3)}${timezone}`;
|
||||
}
|
||||
|
||||
export function parseTimestampMs(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const normalized = normalizeTimestampForDateParse(trimmed);
|
||||
const normalizedParsed = Date.parse(normalized);
|
||||
if (!Number.isNaN(normalizedParsed)) {
|
||||
return normalizedParsed;
|
||||
}
|
||||
|
||||
if (normalized !== trimmed) {
|
||||
const originalParsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(originalParsed)) {
|
||||
return originalParsed;
|
||||
}
|
||||
}
|
||||
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
export function parseTimestamp(value: unknown): Date | null {
|
||||
const timestampMs = parseTimestampMs(value);
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return null;
|
||||
}
|
||||
return new Date(timestampMs);
|
||||
}
|
||||
+243
-78
@@ -4,7 +4,25 @@
|
||||
*/
|
||||
|
||||
import type { ScriptableContext } from 'chart.js';
|
||||
import type { LatencyAccumulator, LatencyStats } from './usage/latency';
|
||||
import {
|
||||
addLatencySample,
|
||||
calculateLatencyStatsFromDetails,
|
||||
createLatencyAccumulator,
|
||||
extractLatencyMs,
|
||||
finalizeLatencyStats,
|
||||
} from './usage/latency';
|
||||
import { maskApiKey } from './format';
|
||||
import { parseTimestampMs } from './timestamp';
|
||||
|
||||
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
|
||||
export {
|
||||
LATENCY_SOURCE_FIELD,
|
||||
LATENCY_SOURCE_UNIT,
|
||||
calculateLatencyStatsFromDetails,
|
||||
extractLatencyMs,
|
||||
formatDurationMs,
|
||||
} from './usage/latency';
|
||||
|
||||
export interface KeyStatBucket {
|
||||
success: number;
|
||||
@@ -38,7 +56,8 @@ export interface ModelPrice {
|
||||
export interface UsageDetail {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
auth_index: number;
|
||||
auth_index: string | number | null;
|
||||
latency_ms?: number;
|
||||
tokens: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -66,7 +85,22 @@ export interface ApiStats {
|
||||
failureCount: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
|
||||
models: Record<
|
||||
string,
|
||||
{ requests: number; successCount: number; failureCount: number; tokens: number }
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ModelStatsSummary {
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
averageLatencyMs: number | null;
|
||||
totalLatencyMs: number | null;
|
||||
latencySampleCount: number;
|
||||
}
|
||||
|
||||
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
|
||||
@@ -77,7 +111,7 @@ const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s
|
||||
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||
'7h': 7 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -100,17 +134,21 @@ const createUsageSummary = (): UsageSummary => ({
|
||||
totalRequests: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
totalTokens: 0
|
||||
totalTokens: 0,
|
||||
});
|
||||
|
||||
const toUsageSummaryFields = (summary: UsageSummary) => ({
|
||||
total_requests: summary.totalRequests,
|
||||
success_count: summary.successCount,
|
||||
failure_count: summary.failureCount,
|
||||
total_tokens: summary.totalTokens
|
||||
total_tokens: summary.totalTokens,
|
||||
});
|
||||
|
||||
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
|
||||
export function filterUsageByTimeRange<T>(
|
||||
usageData: T,
|
||||
range: UsageTimeRange,
|
||||
nowMs: number = Date.now()
|
||||
): T {
|
||||
if (range === 'all') {
|
||||
return usageData;
|
||||
}
|
||||
@@ -158,7 +196,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
if (!detailRecord || typeof detailRecord.timestamp !== 'string') {
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.parse(detailRecord.timestamp);
|
||||
const timestamp = parseTimestampMs(detailRecord.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) {
|
||||
return;
|
||||
}
|
||||
@@ -180,7 +218,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
filteredModels[modelName] = {
|
||||
...modelEntry,
|
||||
...toUsageSummaryFields(modelSummary),
|
||||
details: filteredDetails
|
||||
details: filteredDetails,
|
||||
};
|
||||
hasModelData = true;
|
||||
|
||||
@@ -197,7 +235,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
filteredApis[apiName] = {
|
||||
...apiEntry,
|
||||
...toUsageSummaryFields(apiSummary),
|
||||
models: filteredModels
|
||||
models: filteredModels,
|
||||
};
|
||||
|
||||
totalSummary.totalRequests += apiSummary.totalRequests;
|
||||
@@ -209,7 +247,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
|
||||
return {
|
||||
...usageRecord,
|
||||
...toUsageSummaryFields(totalSummary),
|
||||
apis: filteredApis
|
||||
apis: filteredApis,
|
||||
} as T;
|
||||
}
|
||||
|
||||
@@ -309,7 +347,8 @@ export function normalizeUsageSourceId(
|
||||
value: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): string {
|
||||
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||
const raw =
|
||||
typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
@@ -325,7 +364,10 @@ export function normalizeUsageSourceId(
|
||||
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
|
||||
}
|
||||
|
||||
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
|
||||
export function buildCandidateUsageSourceIds(input: {
|
||||
apiKey?: string;
|
||||
prefix?: string;
|
||||
}): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
const prefix = input.prefix?.trim();
|
||||
@@ -335,6 +377,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?:
|
||||
|
||||
const apiKey = input.apiKey?.trim();
|
||||
if (apiKey) {
|
||||
// Include the normalised form first so that "non-standard" keys (e.g. short tokens,
|
||||
// keys containing '/' etc.) that are classified as text by normalizeUsageSourceId()
|
||||
// can still match usage details.
|
||||
result.push(normalizeUsageSourceId(apiKey));
|
||||
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
|
||||
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
|
||||
}
|
||||
@@ -345,7 +391,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?:
|
||||
/**
|
||||
* 对使用数据中的敏感字段进行遮罩
|
||||
*/
|
||||
export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => string = maskApiKey): string {
|
||||
export function maskUsageSensitiveValue(
|
||||
value: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
@@ -357,12 +406,20 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
|
||||
let masked = raw;
|
||||
|
||||
const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/gi;
|
||||
masked = masked.replace(queryRegex, (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`);
|
||||
masked = masked.replace(
|
||||
queryRegex,
|
||||
(_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`
|
||||
);
|
||||
|
||||
const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi;
|
||||
masked = masked.replace(headerRegex, (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`);
|
||||
const headerRegex =
|
||||
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi;
|
||||
masked = masked.replace(
|
||||
headerRegex,
|
||||
(_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`
|
||||
);
|
||||
|
||||
const keyLikeRegex = /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
|
||||
const keyLikeRegex =
|
||||
/(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
|
||||
masked = masked.replace(keyLikeRegex, (match) => masker(match));
|
||||
|
||||
if (masked === raw) {
|
||||
@@ -436,7 +493,7 @@ export function formatUsd(value: number): string {
|
||||
const fixed = num.toFixed(2);
|
||||
const parts = Number(fixed).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return `$${parts}`;
|
||||
}
|
||||
@@ -489,12 +546,18 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
modelDetails.forEach((detailRaw) => {
|
||||
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const timestampMs = parseTimestampMs(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
const latencyMs = extractLatencyMs(detailRaw);
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
auth_index:
|
||||
(detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
@@ -560,12 +623,18 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
|
||||
modelDetails.forEach((detailRaw) => {
|
||||
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const timestampMs = parseTimestampMs(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
const latencyMs = extractLatencyMs(detailRaw);
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
auth_index:
|
||||
(detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
@@ -605,6 +674,13 @@ export function extractTotalTokens(detail: unknown): number {
|
||||
return inputTokens + outputTokens + reasoningTokens + cachedTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算耗时统计
|
||||
*/
|
||||
export function calculateLatencyStats(usageData: unknown): LatencyStats {
|
||||
return calculateLatencyStatsFromDetails(collectUsageDetails(usageData));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 token 分类统计
|
||||
*/
|
||||
@@ -652,7 +728,9 @@ export function calculateRecentPerMinuteRates(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
@@ -666,7 +744,7 @@ export function calculateRecentPerMinuteRates(
|
||||
tpm: tokenCount / denominator,
|
||||
windowMinutes: effectiveWindow,
|
||||
requestCount,
|
||||
tokenCount
|
||||
tokenCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,7 +772,10 @@ export function getModelNamesFromUsage(usageData: unknown): string[] {
|
||||
/**
|
||||
* 计算成本数据
|
||||
*/
|
||||
export function calculateCost(detail: UsageDetail, modelPrices: Record<string, ModelPrice>): number {
|
||||
export function calculateCost(
|
||||
detail: UsageDetail,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): number {
|
||||
const modelName = detail.__modelName || '';
|
||||
const price = modelPrices[modelName];
|
||||
if (!price) {
|
||||
@@ -707,7 +788,9 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
|
||||
const rawCachedTokensAlternate = Number(tokens.cache_tokens);
|
||||
|
||||
const inputTokens = Number.isFinite(rawInputTokens) ? Math.max(rawInputTokens, 0) : 0;
|
||||
const completionTokens = Number.isFinite(rawCompletionTokens) ? Math.max(rawCompletionTokens, 0) : 0;
|
||||
const completionTokens = Number.isFinite(rawCompletionTokens)
|
||||
? Math.max(rawCompletionTokens, 0)
|
||||
: 0;
|
||||
const cachedTokens = Math.max(
|
||||
Number.isFinite(rawCachedTokensPrimary) ? Math.max(rawCachedTokensPrimary, 0) : 0,
|
||||
Number.isFinite(rawCachedTokensAlternate) ? Math.max(rawCachedTokensAlternate, 0) : 0
|
||||
@@ -716,7 +799,8 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
|
||||
|
||||
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
|
||||
const cachedCost = (cachedTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.cache) || 0);
|
||||
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||
const completionCost =
|
||||
(completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||
const total = promptCost + cachedCost + completionCost;
|
||||
return Number.isFinite(total) && total > 0 ? total : 0;
|
||||
}
|
||||
@@ -724,7 +808,10 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
|
||||
/**
|
||||
* 计算总成本
|
||||
*/
|
||||
export function calculateTotalCost(usageData: unknown, modelPrices: Record<string, ModelPrice>): number {
|
||||
export function calculateTotalCost(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): number {
|
||||
const details = collectUsageDetails(usageData);
|
||||
if (!details.length || !Object.keys(modelPrices).length) {
|
||||
return 0;
|
||||
@@ -756,7 +843,11 @@ export function loadModelPrices(): Record<string, ModelPrice> {
|
||||
const completionRaw = Number(priceRecord?.completion);
|
||||
const cacheRaw = Number(priceRecord?.cache);
|
||||
|
||||
if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) {
|
||||
if (
|
||||
!Number.isFinite(promptRaw) &&
|
||||
!Number.isFinite(completionRaw) &&
|
||||
!Number.isFinite(cacheRaw)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -772,7 +863,7 @@ export function loadModelPrices(): Record<string, ModelPrice> {
|
||||
normalized[model] = {
|
||||
prompt,
|
||||
completion,
|
||||
cache
|
||||
cache,
|
||||
};
|
||||
});
|
||||
return normalized;
|
||||
@@ -798,14 +889,20 @@ export function saveModelPrices(prices: Record<string, ModelPrice>): void {
|
||||
/**
|
||||
* 获取 API 统计数据
|
||||
*/
|
||||
export function getApiStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): ApiStats[] {
|
||||
export function getApiStats(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): ApiStats[] {
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) return [];
|
||||
const result: ApiStats[] = [];
|
||||
|
||||
Object.entries(apis).forEach(([endpoint, apiData]) => {
|
||||
if (!isRecord(apiData)) return;
|
||||
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
|
||||
const models: Record<
|
||||
string,
|
||||
{ requests: number; successCount: number; failureCount: number; tokens: number }
|
||||
> = {};
|
||||
let derivedSuccessCount = 0;
|
||||
let derivedFailureCount = 0;
|
||||
let totalCost = 0;
|
||||
@@ -849,7 +946,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
requests: Number(modelData.total_requests) || 0,
|
||||
successCount,
|
||||
failureCount,
|
||||
tokens: Number(modelData.total_tokens) || 0
|
||||
tokens: Number(modelData.total_tokens) || 0,
|
||||
};
|
||||
derivedSuccessCount += successCount;
|
||||
derivedFailureCount += failureCount;
|
||||
@@ -858,10 +955,10 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
const hasApiExplicitCounts =
|
||||
typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number';
|
||||
const successCount = hasApiExplicitCounts
|
||||
? (Number(apiData.success_count) || 0)
|
||||
? Number(apiData.success_count) || 0
|
||||
: derivedSuccessCount;
|
||||
const failureCount = hasApiExplicitCounts
|
||||
? (Number(apiData.failure_count) || 0)
|
||||
? Number(apiData.failure_count) || 0
|
||||
: derivedFailureCount;
|
||||
|
||||
result.push({
|
||||
@@ -871,7 +968,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
failureCount,
|
||||
totalTokens: Number(apiData.total_tokens) || 0,
|
||||
totalCost,
|
||||
models
|
||||
models,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -881,18 +978,24 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
|
||||
/**
|
||||
* 获取模型统计数据
|
||||
*/
|
||||
export function getModelStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): Array<{
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}> {
|
||||
export function getModelStats(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): ModelStatsSummary[] {
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) return [];
|
||||
|
||||
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
latency: LatencyAccumulator;
|
||||
}
|
||||
>();
|
||||
|
||||
Object.values(apis).forEach((apiData) => {
|
||||
if (!isRecord(apiData)) return;
|
||||
@@ -902,7 +1005,14 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
|
||||
Object.entries(models).forEach(([modelName, modelData]) => {
|
||||
if (!isRecord(modelData)) return;
|
||||
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
|
||||
const existing = modelMap.get(modelName) || {
|
||||
requests: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
latency: createLatencyAccumulator(),
|
||||
};
|
||||
existing.requests += Number(modelData.total_requests) || 0;
|
||||
existing.tokens += Number(modelData.total_tokens) || 0;
|
||||
|
||||
@@ -917,9 +1027,10 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
existing.failureCount += Number(modelData.failure_count) || 0;
|
||||
}
|
||||
|
||||
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
||||
if (details.length > 0) {
|
||||
details.forEach((detail) => {
|
||||
const detailRecord = isRecord(detail) ? detail : null;
|
||||
const latencyMs = extractLatencyMs(detailRecord);
|
||||
if (!hasExplicitCounts) {
|
||||
if (detailRecord?.failed === true) {
|
||||
existing.failureCount += 1;
|
||||
@@ -928,6 +1039,8 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
}
|
||||
}
|
||||
|
||||
addLatencySample(existing.latency, latencyMs);
|
||||
|
||||
if (price && detailRecord) {
|
||||
existing.cost += calculateCost(
|
||||
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
|
||||
@@ -941,7 +1054,20 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
|
||||
});
|
||||
|
||||
return Array.from(modelMap.entries())
|
||||
.map(([model, stats]) => ({ model, ...stats }))
|
||||
.map(([model, stats]) => {
|
||||
const latencyStats = finalizeLatencyStats(stats.latency);
|
||||
return {
|
||||
model,
|
||||
requests: stats.requests,
|
||||
successCount: stats.successCount,
|
||||
failureCount: stats.failureCount,
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
averageLatencyMs: latencyStats.averageMs,
|
||||
totalLatencyMs: latencyStats.totalMs,
|
||||
latencySampleCount: latencyStats.sampleCount,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.requests - a.requests);
|
||||
}
|
||||
|
||||
@@ -1012,7 +1138,9 @@ export function buildHourlySeriesByModel(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1069,7 +1197,9 @@ export function buildDailySeriesByModel(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1092,7 +1222,7 @@ export function buildDailySeriesByModel(
|
||||
const labels = Array.from(labelsSet).sort();
|
||||
const dataByModel = new Map<string, number[]>();
|
||||
valuesByModel.forEach((dayMap, modelName) => {
|
||||
const series = labels.map(label => dayMap.get(label) || 0);
|
||||
const series = labels.map((label) => dayMap.get(label) || 0);
|
||||
dataByModel.set(modelName, series);
|
||||
});
|
||||
|
||||
@@ -1103,7 +1233,10 @@ export interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor: string;
|
||||
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
||||
backgroundColor:
|
||||
| string
|
||||
| CanvasGradient
|
||||
| ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
||||
pointBackgroundColor?: string;
|
||||
pointBorderColor?: string;
|
||||
fill: boolean;
|
||||
@@ -1152,7 +1285,11 @@ const withAlpha = (hex: string, alpha: number) => {
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`;
|
||||
};
|
||||
|
||||
const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => {
|
||||
const buildAreaGradient = (
|
||||
context: ScriptableContext<'line'>,
|
||||
baseHex: string,
|
||||
fallback: string
|
||||
) => {
|
||||
const chart = context.chart;
|
||||
const ctx = chart.ctx;
|
||||
const area = chart.chartArea;
|
||||
@@ -1178,16 +1315,17 @@ export function buildChartData(
|
||||
selectedModels: string[] = [],
|
||||
options: { hourWindowHours?: number } = {}
|
||||
): ChartData {
|
||||
const baseSeries = period === 'hour'
|
||||
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
|
||||
: buildDailySeriesByModel(usageData, metric);
|
||||
const baseSeries =
|
||||
period === 'hour'
|
||||
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
|
||||
: buildDailySeriesByModel(usageData, metric);
|
||||
|
||||
const { labels, dataByModel } = baseSeries;
|
||||
|
||||
// Build "All" series as sum of all models
|
||||
const getAllSeries = (): number[] => {
|
||||
const summed = new Array(labels.length).fill(0);
|
||||
dataByModel.forEach(values => {
|
||||
dataByModel.forEach((values) => {
|
||||
values.forEach((value, idx) => {
|
||||
summed[idx] = (summed[idx] || 0) + value;
|
||||
});
|
||||
@@ -1200,7 +1338,9 @@ export function buildChartData(
|
||||
|
||||
const datasets: ChartDataset[] = modelsToShow.map((model, index) => {
|
||||
const isAll = model === 'all';
|
||||
const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0));
|
||||
const data = isAll
|
||||
? getAllSeries()
|
||||
: dataByModel.get(model) || new Array(labels.length).fill(0);
|
||||
const colorIndex = index % CHART_COLORS.length;
|
||||
const style = CHART_COLORS[colorIndex];
|
||||
const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1);
|
||||
@@ -1215,7 +1355,7 @@ export function buildChartData(
|
||||
pointBackgroundColor: style.borderColor,
|
||||
pointBorderColor: style.borderColor,
|
||||
fill: shouldFill,
|
||||
tension: 0.35
|
||||
tension: 0.35,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1262,7 +1402,7 @@ export interface StatusBarData {
|
||||
export function calculateStatusBarData(
|
||||
usageDetails: UsageDetail[],
|
||||
sourceFilter?: string,
|
||||
authIndexFilter?: number
|
||||
authIndexFilter?: string | number
|
||||
): StatusBarData {
|
||||
const BLOCK_COUNT = 20;
|
||||
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
||||
@@ -1283,8 +1423,15 @@ export function calculateStatusBarData(
|
||||
// Filter and bucket the usage details
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
timestamp < windowStart ||
|
||||
timestamp > now
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1346,7 +1493,7 @@ export function calculateStatusBarData(
|
||||
blockDetails,
|
||||
successRate,
|
||||
totalSuccess,
|
||||
totalFailure
|
||||
totalFailure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1364,9 +1511,7 @@ export interface ServiceHealthData {
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export function calculateServiceHealthData(
|
||||
usageDetails: UsageDetail[]
|
||||
): ServiceHealthData {
|
||||
export function calculateServiceHealthData(usageDetails: UsageDetail[]): ServiceHealthData {
|
||||
const ROWS = 7;
|
||||
const COLS = 96;
|
||||
const BLOCK_COUNT = ROWS * COLS; // 672
|
||||
@@ -1386,8 +1531,15 @@ export function calculateServiceHealthData(
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
timestamp < windowStart ||
|
||||
timestamp > now
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1444,7 +1596,10 @@ export function calculateServiceHealthData(
|
||||
};
|
||||
}
|
||||
|
||||
export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
|
||||
export function computeKeyStats(
|
||||
usageData: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): KeyStats {
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!apis) {
|
||||
return { bySource: {}, byAuthIndex: {} };
|
||||
@@ -1499,7 +1654,7 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
|
||||
|
||||
return {
|
||||
bySource: sourceStats,
|
||||
byAuthIndex: authIndexStats
|
||||
byAuthIndex: authIndexStats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1586,7 +1741,9 @@ export function buildHourlyTokenBreakdown(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1601,9 +1758,10 @@ export function buildHourlyTokenBreakdown(
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = 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,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
const reasoning =
|
||||
typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dataByCategory.input[bucketIndex] += input;
|
||||
dataByCategory.output[bucketIndex] += output;
|
||||
@@ -1625,7 +1783,9 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
@@ -1639,9 +1799,10 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = 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,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
const reasoning =
|
||||
typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dayMap[dayLabel].input += input;
|
||||
dayMap[dayLabel].output += output;
|
||||
@@ -1699,7 +1860,9 @@ export function buildHourlyCostSeries(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1732,7 +1895,9 @@ export function buildDailyCostSeries(
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export const LATENCY_SOURCE_FIELD = 'latency_ms';
|
||||
export const LATENCY_SOURCE_UNIT = 'ms';
|
||||
|
||||
export interface LatencyStats {
|
||||
averageMs: number | null;
|
||||
totalMs: number | null;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
export interface DurationFormatOptions {
|
||||
maxUnits?: number;
|
||||
invalidText?: string;
|
||||
secondDecimals?: number | 'auto';
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface LatencyAccumulator {
|
||||
totalMs: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const normalizeDurationMaxUnits = (value: number | undefined): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 2;
|
||||
}
|
||||
return Math.min(Math.floor(parsed), 4);
|
||||
};
|
||||
|
||||
const resolveSecondDecimalPlaces = (
|
||||
seconds: number,
|
||||
secondDecimals: number | 'auto' | undefined
|
||||
): number => {
|
||||
if (secondDecimals === 'auto' || secondDecimals === undefined) {
|
||||
return seconds < 10 ? 2 : 1;
|
||||
}
|
||||
|
||||
const parsed = Math.floor(Number(secondDecimals));
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return seconds < 10 ? 2 : 1;
|
||||
}
|
||||
return Math.min(parsed, 3);
|
||||
};
|
||||
|
||||
const resolveDurationLocale = (locale?: string): string | undefined =>
|
||||
locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined;
|
||||
|
||||
const formatDurationNumber = (
|
||||
value: number,
|
||||
locale: string | undefined,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
useGrouping: false,
|
||||
...options,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string =>
|
||||
i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit });
|
||||
|
||||
const formatDurationPart = (
|
||||
value: number,
|
||||
unit: 'd' | 'h' | 'm' | 's' | 'ms',
|
||||
locale: string | undefined,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`;
|
||||
|
||||
/**
|
||||
* 从后端字段 latency_ms 提取耗时,并按毫秒解释。
|
||||
*/
|
||||
export function extractLatencyMs(detail: unknown): number | null {
|
||||
const record = isRecord(detail) ? detail : null;
|
||||
const rawValue = record?.[LATENCY_SOURCE_FIELD];
|
||||
if (
|
||||
rawValue === null ||
|
||||
rawValue === undefined ||
|
||||
(typeof rawValue === 'string' && rawValue.trim() === '')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const createLatencyAccumulator = (): LatencyAccumulator => ({
|
||||
totalMs: 0,
|
||||
sampleCount: 0,
|
||||
});
|
||||
|
||||
export const addLatencySample = (
|
||||
accumulator: LatencyAccumulator,
|
||||
latencyMs: number | null | undefined
|
||||
): void => {
|
||||
if (latencyMs === null || latencyMs === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(latencyMs);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
accumulator.totalMs += parsed;
|
||||
accumulator.sampleCount += 1;
|
||||
};
|
||||
|
||||
export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
|
||||
averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
|
||||
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
|
||||
sampleCount: accumulator.sampleCount,
|
||||
});
|
||||
|
||||
/**
|
||||
* 从明细列表计算耗时统计
|
||||
*/
|
||||
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
|
||||
const accumulator = createLatencyAccumulator();
|
||||
for (const detail of details) {
|
||||
addLatencySample(accumulator, extractLatencyMs(detail));
|
||||
}
|
||||
return finalizeLatencyStats(accumulator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前语言格式化耗时显示。
|
||||
*/
|
||||
export function formatDurationMs(
|
||||
value: number | null | undefined,
|
||||
options: DurationFormatOptions = {}
|
||||
): string {
|
||||
const invalidText = options.invalidText ?? '--';
|
||||
if (value === null || value === undefined) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
const locale = resolveDurationLocale(options.locale);
|
||||
|
||||
if (parsed < 1000) {
|
||||
return formatDurationPart(Math.round(parsed), 'ms', locale);
|
||||
}
|
||||
|
||||
const seconds = parsed / 1000;
|
||||
if (seconds < 60) {
|
||||
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
|
||||
return formatDurationPart(seconds, 's', locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: secondDecimalPlaces,
|
||||
});
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
let remainingSeconds = totalSeconds;
|
||||
const days = Math.floor(remainingSeconds / 86_400);
|
||||
remainingSeconds -= days * 86_400;
|
||||
const hours = Math.floor(remainingSeconds / 3_600);
|
||||
remainingSeconds -= hours * 3_600;
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
remainingSeconds -= minutes * 60;
|
||||
|
||||
const parts = [
|
||||
{ unit: 'd' as const, value: days },
|
||||
{ unit: 'h' as const, value: hours },
|
||||
{ unit: 'm' as const, value: minutes },
|
||||
{ unit: 's' as const, value: remainingSeconds },
|
||||
].filter((part) => part.value > 0);
|
||||
|
||||
if (!parts.length) {
|
||||
return formatDurationPart(0, 's', locale);
|
||||
}
|
||||
|
||||
return parts
|
||||
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
|
||||
.map((part, index) =>
|
||||
formatDurationPart(part.value, part.unit, locale, {
|
||||
minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
+36
-2
@@ -1,6 +1,8 @@
|
||||
import type { UsageDetail } from '@/utils/usage';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
|
||||
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
|
||||
export type UsageDetailsByAuthIndex = Map<string, UsageDetail[]>;
|
||||
|
||||
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
|
||||
|
||||
@@ -22,15 +24,47 @@ export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDet
|
||||
return map;
|
||||
}
|
||||
|
||||
export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex {
|
||||
const map: UsageDetailsByAuthIndex = new Map();
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const authIndexKey = normalizeAuthIndex(detail.auth_index);
|
||||
if (!authIndexKey) return;
|
||||
|
||||
const bucket = map.get(authIndexKey);
|
||||
if (bucket) {
|
||||
bucket.push(detail);
|
||||
} else {
|
||||
map.set(authIndexKey, [detail]);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function collectUsageDetailsForCandidates(
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
candidates: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
return collectUsageDetailsForKeys(usageDetailsBySource, candidates);
|
||||
}
|
||||
|
||||
export function collectUsageDetailsForAuthIndices(
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex,
|
||||
authIndices: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices);
|
||||
}
|
||||
|
||||
function collectUsageDetailsForKeys(
|
||||
usageDetailsByKey: Map<string, UsageDetail[]>,
|
||||
keys: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
let firstDetails: UsageDetail[] | null = null;
|
||||
let merged: UsageDetail[] | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const details = usageDetailsBySource.get(candidate);
|
||||
for (const key of keys) {
|
||||
const details = usageDetailsByKey.get(key);
|
||||
if (!details || details.length === 0) continue;
|
||||
|
||||
if (!firstDetails) {
|
||||
|
||||
Reference in New Issue
Block a user