Compare commits

...

24 Commits

44 changed files with 3301 additions and 899 deletions
+1
View File
@@ -23,6 +23,7 @@ skills
# Editor directories and files
settings.local.json
.codex
.vscode/*
!.vscode/extensions.json
.idea
@@ -888,6 +888,13 @@ export function VisualConfigEditor({
}
/>
</FieldShell>
<Input
label={t('config_management.visual.sections.network.session_affinity_ttl')}
placeholder="1h"
value={values.routingSessionAffinityTTL}
onChange={(e) => onChange({ routingSessionAffinityTTL: e.target.value })}
disabled={disabled}
/>
</SectionGrid>
<SectionGrid>
@@ -900,6 +907,12 @@ export function VisualConfigEditor({
disabled={disabled}
onChange={(forceModelPrefix) => onChange({ forceModelPrefix })}
/>
<ToggleRow
title={t('config_management.visual.sections.network.session_affinity')}
checked={values.routingSessionAffinity}
disabled={disabled}
onChange={(routingSessionAffinity) => onChange({ routingSessionAffinity })}
/>
<ToggleRow
title={t('config_management.visual.sections.network.ws_auth')}
description={t('config_management.visual.sections.network.ws_auth_desc')}
@@ -936,6 +949,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>
+172 -16
View File
@@ -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 ?? '',
+12 -1
View File
@@ -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) {
@@ -970,6 +974,13 @@ const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string |
const hasClaudePro = normalizeFlagValue(profile.account?.has_claude_pro);
if (hasClaudePro) return 'plan_pro';
const organizationType = normalizeStringValue(profile.organization?.organization_type)?.toLowerCase();
const subscriptionStatus = normalizeStringValue(profile.organization?.subscription_status)?.toLowerCase();
if (organizationType === 'claude_team' && subscriptionStatus === 'active') {
return 'plan_team';
}
if (hasClaudeMax === false && hasClaudePro === false) return 'plan_free';
return null;
+106 -248
View File
@@ -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>
+152 -92
View File
@@ -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>
)}
+108 -36
View File
@@ -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>
+62 -21
View File
@@ -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>
)}
@@ -65,7 +65,13 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
disabled={
disableControls ||
editor?.saving === true ||
!dirty ||
!editor?.json ||
Boolean(editor?.headersTouched && editor.headersError)
}
>
{t('common.save')}
</Button>
@@ -138,6 +144,20 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<div className="form-group">
<label>{t('auth_files.headers_label')}</label>
<textarea
className={`input ${editor.headersError ? styles.prefixProxyTextareaInvalid : ''}`}
value={editor.headersText}
placeholder={t('auth_files.headers_placeholder')}
rows={4}
aria-invalid={Boolean(editor.headersError)}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('headersText', e.target.value)}
/>
{editor.headersError && <div className="error-box">{editor.headersError}</div>}
<div className="hint">{t('auth_files.headers_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={editor.disableCooling}
+2 -1
View File
@@ -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();
};
@@ -14,6 +14,12 @@ import {
readCodexAuthFileWebsockets,
} from '@/features/authFiles/constants';
type AuthFileHeaders = Record<string, string>;
type AuthFileHeadersErrorKey =
| 'auth_files.headers_invalid_json'
| 'auth_files.headers_invalid_object'
| 'auth_files.headers_invalid_value';
export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
@@ -21,7 +27,8 @@ export type PrefixProxyEditorField =
| 'excludedModelsText'
| 'disableCooling'
| 'websockets'
| 'note';
| 'note'
| 'headersText';
export type PrefixProxyEditorFieldValue = string | boolean;
@@ -43,6 +50,9 @@ export type PrefixProxyEditorState = {
websockets: boolean;
note: string;
noteTouched: boolean;
headersText: string;
headersTouched: boolean;
headersError: string | null;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
@@ -64,7 +74,45 @@ export type UseAuthFilesPrefixProxyEditorResult = {
handlePrefixProxySave: () => Promise<void>;
};
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const validateHeadersValue = (value: unknown): AuthFileHeadersErrorKey | null => {
if (!isRecordObject(value)) {
return 'auth_files.headers_invalid_object';
}
return Object.values(value).every((item) => typeof item === 'string')
? null
: 'auth_files.headers_invalid_value';
};
const parseHeadersText = (
text: string
): { value: AuthFileHeaders | null; errorKey: AuthFileHeadersErrorKey | null } => {
const trimmed = text.trim();
if (!trimmed) {
return { value: null, errorKey: null };
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return { value: null, errorKey: 'auth_files.headers_invalid_json' };
}
const errorKey = validateHeadersValue(parsed);
if (errorKey) {
return { value: null, errorKey };
}
return { value: parsed as AuthFileHeaders, errorKey: null };
};
const buildPrefixProxyUpdatedText = (
editor: PrefixProxyEditorState | null,
resolveHeadersError: (key: AuthFileHeadersErrorKey) => string
): string => {
if (!editor?.json) return editor?.rawText ?? '';
const next: Record<string, unknown> = { ...editor.json };
if ('prefix' in next || editor.prefix.trim()) {
@@ -104,6 +152,18 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
}
}
if (editor.headersTouched) {
const { value: parsedHeaders, errorKey } = parseHeadersText(editor.headersText);
if (errorKey) {
throw new Error(resolveHeadersError(errorKey));
}
if (parsedHeaders) {
next.headers = parsedHeaders;
} else {
delete next.headers;
}
}
return JSON.stringify(
editor.isCodexFile ? applyCodexAuthFileWebsockets(next, editor.websockets) : next
);
@@ -118,11 +178,18 @@ export function useAuthFilesPrefixProxyEditor(
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
const hasBlockingValidationError = Boolean(
prefixProxyEditor?.headersTouched && prefixProxyEditor.headersError
);
const prefixProxyUpdatedText =
prefixProxyEditor?.json && !hasBlockingValidationError
? buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key))
: '';
const prefixProxyDirty =
Boolean(prefixProxyEditor?.json) &&
Boolean(prefixProxyEditor?.originalText) &&
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
(prefixProxyUpdatedText === '' || prefixProxyUpdatedText !== prefixProxyEditor?.originalText);
const closePrefixProxyEditor = () => {
setPrefixProxyEditor(null);
@@ -162,6 +229,9 @@ export function useAuthFilesPrefixProxyEditor(
websockets: false,
note: '',
noteTouched: false,
headersText: '',
headersTouched: false,
headersError: null,
});
try {
@@ -213,6 +283,14 @@ export function useAuthFilesPrefixProxyEditor(
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
const websocketsValue = readCodexAuthFileWebsockets(json);
const note = typeof json.note === 'string' ? json.note : '';
const headers = json.headers;
let headersText = '';
let headersError: string | null = null;
if (headers !== undefined) {
headersText = JSON.stringify(headers, null, 2);
const { errorKey } = parseHeadersText(headersText);
headersError = errorKey ? t(errorKey) : null;
}
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -231,6 +309,9 @@ export function useAuthFilesPrefixProxyEditor(
websockets: websocketsValue,
note,
noteTouched: false,
headersText,
headersTouched: false,
headersError,
error: null,
};
});
@@ -256,6 +337,16 @@ export function useAuthFilesPrefixProxyEditor(
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
if (field === 'headersText') {
const headersText = String(value);
const { errorKey } = parseHeadersText(headersText);
return {
...prev,
headersText,
headersTouched: true,
headersError: errorKey ? t(errorKey) : null,
};
}
return { ...prev, websockets: Boolean(value) };
});
};
@@ -265,7 +356,15 @@ export function useAuthFilesPrefixProxyEditor(
if (!prefixProxyDirty) return;
const name = prefixProxyEditor.fileName;
const payload = prefixProxyUpdatedText;
let payload = '';
try {
payload = buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Invalid format';
showNotification(errorMessage, 'error');
return;
}
const fileSize = new Blob([payload]).size;
if (fileSize > MAX_AUTH_FILE_SIZE) {
showNotification(
+50 -2
View File
@@ -647,9 +647,27 @@ 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);
}
if (Object.prototype.hasOwnProperty.call(patch, 'routingSessionAffinity')) {
updateDirty(
'routingSessionAffinity',
nextValues.routingSessionAffinity === baselineValues.routingSessionAffinity
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'routingSessionAffinityTTL')) {
updateDirty(
'routingSessionAffinityTTL',
nextValues.routingSessionAffinityTTL === baselineValues.routingSessionAffinityTTL
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
updateDirty(
'payloadDefaultRules',
@@ -827,8 +845,22 @@ 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',
routingSessionAffinity: Boolean(
routing?.['session-affinity'] ??
routing?.sessionAffinity ??
routing?.['sessionAffinity']
),
routingSessionAffinityTTL:
typeof routing?.['session-affinity-ttl'] === 'string'
? routing['session-affinity-ttl']
: typeof routing?.sessionAffinityTTL === 'string'
? routing.sessionAffinityTTL
: typeof routing?.['sessionAffinityTTL'] === 'string'
? routing['sessionAffinityTTL']
: '',
payloadDefaultRules: parsePayloadRules(payload?.default),
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
@@ -929,17 +961,33 @@ 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']);
}
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
if (
docHas(doc, ['routing']) ||
values.routingStrategy !== 'round-robin' ||
values.routingSessionAffinity ||
values.routingSessionAffinityTTL.trim()
) {
ensureMapInDoc(doc, ['routing']);
doc.setIn(['routing', 'strategy'], values.routingStrategy);
setBooleanInDoc(doc, ['routing', 'session-affinity'], values.routingSessionAffinity);
setStringInDoc(
doc,
['routing', 'session-affinity-ttl'],
values.routingSessionAffinityTTL
);
deleteIfMapEmpty(doc, ['routing']);
}
+2
View File
@@ -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 }
},
+25 -31
View File
@@ -597,6 +597,12 @@
"note_placeholder": "Enter a note, e.g.: John's account",
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
"note_display": "Note",
"headers_label": "Custom Headers (headers)",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}",
"headers_invalid_json": "Custom headers must be valid JSON.",
"headers_invalid_object": "Custom headers must be a JSON object.",
"headers_invalid_value": "Each custom header value must be a string.",
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
@@ -639,7 +645,8 @@
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
"plan_max20": "Max 20x",
"plan_team": "Team"
},
"codex_quota": {
"title": "Codex Quota",
@@ -664,7 +671,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",
@@ -884,17 +892,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.",
@@ -917,23 +914,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": {
@@ -1023,6 +1003,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",
@@ -1243,8 +1232,10 @@
"routing_strategy_hint": "Select credential selection strategy",
"strategy_round_robin": "Round Robin",
"strategy_fill_first": "Fill First",
"session_affinity_ttl": "Session Affinity TTL",
"force_model_prefix": "Force Model Prefix",
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
"session_affinity": "Session Affinity Routing",
"ws_auth": "WebSocket Authentication",
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)"
},
@@ -1254,7 +1245,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",
@@ -1469,6 +1462,7 @@
"language": {
"switch": "Language",
"chinese": "中文",
"chinese_tw": "Traditional Chinese (Taiwan)",
"english": "English",
"russian": "Русский"
},
+19 -31
View File
@@ -642,7 +642,8 @@
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
"plan_max20": "Max 20x",
"plan_team": "Team"
},
"codex_quota": {
"title": "Квота Codex",
@@ -667,7 +668,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 +889,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 +911,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 +1000,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": "Совпадений не найдено",
@@ -1248,8 +1231,10 @@
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
"strategy_round_robin": "По кругу",
"strategy_fill_first": "Сначала заполнить",
"session_affinity_ttl": "TTL привязки сессии",
"force_model_prefix": "Принудительный префикс модели",
"force_model_prefix_desc": "Запросы к моделям без префикса используют только учётные данные без префикса",
"session_affinity": "Маршрутизация с привязкой к сессии",
"ws_auth": "Аутентификация WebSocket",
"ws_auth_desc": "Включить аутентификацию WebSocket (/v1/ws)"
},
@@ -1259,7 +1244,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 +1461,7 @@
"language": {
"switch": "Язык",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
+25 -31
View File
@@ -597,6 +597,12 @@
"note_placeholder": "输入备注信息,例如:张三的账号",
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
"note_display": "备注",
"headers_label": "自定义请求头(headers",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}",
"headers_invalid_json": "自定义请求头必须是有效的 JSON。",
"headers_invalid_object": "自定义请求头必须是 JSON 对象。",
"headers_invalid_value": "每个自定义请求头的值都必须是字符串。",
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
@@ -639,7 +645,8 @@
"plan_pro": "专业版",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
"plan_max20": "Max 20x",
"plan_team": "团队版"
},
"codex_quota": {
"title": "Codex 额度",
@@ -664,7 +671,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 额度",
@@ -884,17 +892,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 并提交到这里。",
@@ -917,23 +914,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": {
@@ -1023,6 +1003,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": "没有匹配结果",
@@ -1243,8 +1232,10 @@
"routing_strategy_hint": "选择凭据选择策略",
"strategy_round_robin": "轮询 (Round Robin)",
"strategy_fill_first": "填充优先 (Fill First)",
"session_affinity_ttl": "会话粘性 TTL",
"force_model_prefix": "强制模型前缀",
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
"session_affinity": "会话粘性路由",
"ws_auth": "WebSocket 认证",
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)"
},
@@ -1254,7 +1245,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": "流式传输配置",
@@ -1469,6 +1462,7 @@
"language": {
"switch": "语言",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -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}
+5
View File
@@ -1384,6 +1384,11 @@
}
}
.prefixProxyTextareaInvalid {
border-color: var(--danger-color);
box-shadow: 0 0 0 3px rgba($error-color, 0.12);
}
.cardActions {
display: flex;
align-items: center;
+2 -128
View File
@@ -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>
);
+25 -6
View File
@@ -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 & {
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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;
}
+2 -16
View File
@@ -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 })
}
};
+30 -2
View File
@@ -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
View File
@@ -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';
+1
View File
@@ -9,6 +9,7 @@ import type { AmpcodeConfig } from './ampcode';
export interface QuotaExceededConfig {
switchProject?: boolean;
switchPreviewModel?: boolean;
antigravityCredits?: boolean;
}
export interface Config {
+1 -2
View File
@@ -9,8 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
| 'kimi';
// OAuth 流程状态
export interface OAuthFlow {
+4
View File
@@ -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
View File
@@ -1,6 +1,7 @@
export type SourceInfo = {
displayName: string;
type: string;
identityKey?: string;
};
export type CredentialInfo = {
+6
View File
@@ -75,7 +75,10 @@ export type VisualConfigValues = {
maxRetryInterval: string;
quotaSwitchProject: boolean;
quotaSwitchPreviewModel: boolean;
quotaAntigravityCredits: boolean;
routingStrategy: 'round-robin' | 'fill-first';
routingSessionAffinity: boolean;
routingSessionAffinityTTL: string;
wsAuth: boolean;
payloadDefaultRules: PayloadRule[];
payloadDefaultRawRules: PayloadRule[];
@@ -114,7 +117,10 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
maxRetryInterval: '',
quotaSwitchProject: true,
quotaSwitchPreviewModel: true,
quotaAntigravityCredits: true,
routingStrategy: 'round-robin',
routingSessionAffinity: false,
routingSessionAffinityTTL: '',
wsAuth: false,
payloadDefaultRules: [],
payloadDefaultRawRules: [],
+4 -5
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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
View File
@@ -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:-',
};
}
+61
View File
@@ -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
View File
@@ -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;
+199
View File
@@ -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
View File
@@ -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) {