mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
13 Commits
@@ -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')}
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface ClaudeSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function ClaudeSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function ClaudeSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function ClaudeSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -101,12 +103,16 @@ export function ClaudeSection({
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function CodexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function CodexSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function CodexSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -101,12 +103,16 @@ export function CodexSection({
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function GeminiSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function GeminiSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function GeminiSection({
|
||||
<ProviderList<GeminiKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -102,11 +104,15 @@ export function GeminiSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -7,21 +7,24 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
import {
|
||||
collectOpenAIProviderUsageDetails,
|
||||
getOpenAIEntryKey,
|
||||
getOpenAIProviderKey,
|
||||
getOpenAIProviderStats,
|
||||
getStatsForIdentity,
|
||||
} from '../utils';
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -35,6 +38,7 @@ export function OpenAISection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -49,21 +53,22 @@ export function OpenAISection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
configs.forEach((provider, index) => {
|
||||
const providerKey = getOpenAIProviderKey(provider, index);
|
||||
cache.set(
|
||||
providerKey,
|
||||
calculateStatusBarData(
|
||||
collectOpenAIProviderUsageDetails(
|
||||
provider,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,17 +92,18 @@ export function OpenAISection({
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(_, index) => `openai-provider-${index}`}
|
||||
keyField={(item, index) => getOpenAIProviderKey(item, index)}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getOpenAIProviderStats(item, keyStats);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -134,9 +140,15 @@ export function OpenAISection({
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||
const entryStats = getStatsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
keyStats
|
||||
);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<div
|
||||
key={getOpenAIEntryKey(entry, entryIndex)}
|
||||
className={styles.apiKeyEntryCard}
|
||||
>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface VertexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function VertexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function VertexSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export function VertexSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.vertex_empty_title')}
|
||||
emptyDescription={t('ai_providers.vertex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
@@ -102,11 +104,15 @@ export function VertexSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type {
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping,
|
||||
AmpcodeUpstreamApiKeyMapping,
|
||||
ApiKeyEntry,
|
||||
OpenAIProviderConfig,
|
||||
} from '@/types';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
normalizeAuthIndex,
|
||||
type KeyStatBucket,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForAuthIndices,
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsByAuthIndex,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
@@ -109,25 +127,94 @@ export const getStatsBySource = (
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
providerPrefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
type UsageIdentity = {
|
||||
authIndex?: unknown;
|
||||
apiKey?: string;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
export const getStatsForIdentity = (
|
||||
identity: UsageIdentity,
|
||||
keyStats: KeyStats
|
||||
): KeyStatBucket => {
|
||||
const authIndexKey = normalizeAuthIndex(identity.authIndex);
|
||||
if (authIndexKey) {
|
||||
const stats = keyStats.byAuthIndex?.[authIndexKey];
|
||||
if (stats) {
|
||||
return { success: stats.success, failure: stats.failure };
|
||||
}
|
||||
}
|
||||
|
||||
return getStatsBySource(identity.apiKey ?? '', keyStats, identity.prefix);
|
||||
};
|
||||
|
||||
export const collectUsageDetailsForIdentity = (
|
||||
identity: UsageIdentity,
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
|
||||
): UsageDetail[] => {
|
||||
const authIndexKey = normalizeAuthIndex(identity.authIndex);
|
||||
if (authIndexKey) {
|
||||
const details = collectUsageDetailsForAuthIndices(usageDetailsByAuthIndex, [authIndexKey]);
|
||||
if (details.length > 0) {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: identity.apiKey,
|
||||
prefix: identity.prefix,
|
||||
});
|
||||
if (!candidates.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectUsageDetailsForCandidates(usageDetailsBySource, candidates);
|
||||
};
|
||||
|
||||
const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => {
|
||||
let firstDetails: UsageDetail[] | null = null;
|
||||
let merged: UsageDetail[] | null = null;
|
||||
|
||||
groups.forEach((details) => {
|
||||
if (!details.length) return;
|
||||
if (!firstDetails) {
|
||||
firstDetails = details;
|
||||
return;
|
||||
}
|
||||
if (!merged) {
|
||||
merged = [...firstDetails];
|
||||
}
|
||||
merged.push(...details);
|
||||
});
|
||||
|
||||
return merged ?? firstDetails ?? [];
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
provider: OpenAIProviderConfig,
|
||||
keyStats: KeyStats
|
||||
): KeyStatBucket => {
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
sourceIds.forEach((id) => {
|
||||
const stats = bySource[id];
|
||||
if (!stats) return;
|
||||
|
||||
if (!provider.apiKeyEntries?.length) {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: provider.authIndex, prefix: provider.prefix },
|
||||
keyStats
|
||||
);
|
||||
return { success: stats.success, failure: stats.failure };
|
||||
}
|
||||
|
||||
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
|
||||
const prefixStats = getStatsBySource('', keyStats, provider.prefix);
|
||||
success += prefixStats.success;
|
||||
failure += prefixStats.failure;
|
||||
}
|
||||
|
||||
provider.apiKeyEntries.forEach((entry) => {
|
||||
const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats);
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
@@ -135,6 +222,75 @@ export const getOpenAIProviderStats = (
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
export const collectOpenAIProviderUsageDetails = (
|
||||
provider: OpenAIProviderConfig,
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
|
||||
): UsageDetail[] => {
|
||||
if (!provider.apiKeyEntries?.length) {
|
||||
return collectUsageDetailsForIdentity(
|
||||
{ authIndex: provider.authIndex, prefix: provider.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
);
|
||||
}
|
||||
|
||||
const groups: UsageDetail[][] = [];
|
||||
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
|
||||
groups.push(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ prefix: provider.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
provider.apiKeyEntries.forEach((entry) => {
|
||||
groups.push(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return mergeUsageDetails(groups);
|
||||
};
|
||||
|
||||
export const getProviderConfigKey = (
|
||||
config: {
|
||||
authIndex?: unknown;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
proxyUrl?: string;
|
||||
},
|
||||
index: number
|
||||
): string => {
|
||||
const authIndexKey = normalizeAuthIndex(config.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${config.apiKey ?? ''}::${config.baseUrl ?? ''}::${config.proxyUrl ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => {
|
||||
const authIndexKey = normalizeAuthIndex(provider.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${provider.name}::${provider.baseUrl}::${provider.prefix ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => {
|
||||
const authIndexKey = normalizeAuthIndex(entry.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${entry.apiKey}::${entry.proxyUrl ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
apiKey: input?.apiKey ?? '',
|
||||
proxyUrl: input?.proxyUrl ?? '',
|
||||
|
||||
@@ -974,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -29,6 +30,7 @@ type RequestEventRow = {
|
||||
timestampMs: number;
|
||||
timestampLabel: string;
|
||||
model: string;
|
||||
sourceKey: string;
|
||||
sourceRaw: string;
|
||||
source: string;
|
||||
sourceType: string;
|
||||
@@ -125,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;
|
||||
@@ -146,6 +148,7 @@ export function RequestEventsDetailsCard({
|
||||
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);
|
||||
@@ -162,11 +165,12 @@ export function RequestEventsDetailsCard({
|
||||
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,
|
||||
@@ -179,7 +183,41 @@ export function RequestEventsDetailsCard({
|
||||
cachedTokens,
|
||||
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]);
|
||||
|
||||
@@ -196,16 +234,22 @@ export function RequestEventsDetailsCard({
|
||||
[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,
|
||||
...Array.from(optionMap.entries()).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
];
|
||||
}, [rows, t]);
|
||||
|
||||
const authIndexOptions = useMemo(
|
||||
() => [
|
||||
@@ -243,7 +287,7 @@ export function RequestEventsDetailsCard({
|
||||
const modelMatched =
|
||||
effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
|
||||
const sourceMatched =
|
||||
effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
|
||||
effectiveSourceFilter === ALL_FILTER || row.sourceKey === effectiveSourceFilter;
|
||||
const authIndexMatched =
|
||||
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
|
||||
return modelMatched && sourceMatched && authIndexMatched;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -656,6 +656,18 @@ function getNextDirtyFields(
|
||||
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',
|
||||
@@ -836,6 +848,19 @@ export function useVisualConfig() {
|
||||
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']),
|
||||
@@ -949,9 +974,20 @@ export function useVisualConfig() {
|
||||
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']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -645,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",
|
||||
@@ -891,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.",
|
||||
@@ -924,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": {
|
||||
@@ -1259,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)"
|
||||
},
|
||||
@@ -1487,6 +1462,7 @@
|
||||
"language": {
|
||||
"switch": "Language",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "Traditional Chinese (Taiwan)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -888,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 и отправьте его здесь.",
|
||||
@@ -921,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": {
|
||||
@@ -1258,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)"
|
||||
},
|
||||
@@ -1486,6 +1461,7 @@
|
||||
"language": {
|
||||
"switch": "Язык",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "繁體中文(台灣)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
@@ -645,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 额度",
|
||||
@@ -891,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 并提交到这里。",
|
||||
@@ -924,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": {
|
||||
@@ -1259,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)"
|
||||
},
|
||||
@@ -1487,6 +1462,7 @@
|
||||
"language": {
|
||||
"switch": "语言",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "繁體中文(台灣)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { ampcodeApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
export function AiProvidersPage() {
|
||||
@@ -71,6 +71,10 @@ export function AiProvidersPage() {
|
||||
() => indexUsageDetailsBySource(usageDetails),
|
||||
[usageDetails]
|
||||
);
|
||||
const usageDetailsByAuthIndex = useMemo(
|
||||
() => indexUsageDetailsByAuthIndex(usageDetails),
|
||||
[usageDetails]
|
||||
);
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
@@ -378,6 +382,7 @@ export function AiProvidersPage() {
|
||||
configs={geminiKeys}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -393,6 +398,7 @@ export function AiProvidersPage() {
|
||||
configs={codexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -408,6 +414,7 @@ export function AiProvidersPage() {
|
||||
configs={claudeConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -423,6 +430,7 @@ export function AiProvidersPage() {
|
||||
configs={vertexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -448,6 +456,7 @@ export function AiProvidersPage() {
|
||||
configs={openaiProviders}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
|
||||
+2
-128
@@ -4,7 +4,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
|
||||
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import styles from './OAuthPage.module.scss';
|
||||
@@ -14,8 +14,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
|
||||
interface ProviderState {
|
||||
@@ -32,14 +30,6 @@ interface ProviderState {
|
||||
callbackError?: string;
|
||||
}
|
||||
|
||||
interface IFlowCookieState {
|
||||
cookie: string;
|
||||
loading: boolean;
|
||||
result?: IFlowCookieAuthResponse;
|
||||
error?: string;
|
||||
errorType?: 'error' | 'warning';
|
||||
}
|
||||
|
||||
interface VertexImportResult {
|
||||
projectId?: string;
|
||||
email?: string;
|
||||
@@ -76,8 +66,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
|
||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
||||
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } }
|
||||
];
|
||||
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
@@ -94,7 +83,6 @@ export function OAuthPage() {
|
||||
const { showNotification } = useNotificationStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||
fileName: '',
|
||||
location: '',
|
||||
@@ -235,49 +223,6 @@ export function OAuthPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitIflowCookie = async () => {
|
||||
const cookie = iflowCookie.cookie.trim();
|
||||
if (!cookie) {
|
||||
showNotification(t('auth_login.iflow_cookie_required'), 'warning');
|
||||
return;
|
||||
}
|
||||
setIflowCookie((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
result: undefined
|
||||
}));
|
||||
try {
|
||||
const res = await oauthApi.iflowCookieAuth(cookie);
|
||||
if (res.status === 'ok') {
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, result: res }));
|
||||
showNotification(t('auth_login.iflow_cookie_status_success'), 'success');
|
||||
} else {
|
||||
setIflowCookie((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: res.error,
|
||||
errorType: 'error'
|
||||
}));
|
||||
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (getErrorStatus(err) === 409) {
|
||||
const message = t('auth_login.iflow_cookie_config_duplicate');
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
|
||||
showNotification(message, 'warning');
|
||||
return;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
|
||||
showNotification(
|
||||
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVertexFilePick = () => {
|
||||
vertexFileInputRef.current?.click();
|
||||
};
|
||||
@@ -542,77 +487,6 @@ export function OAuthPage() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* iFlow Cookie 登录 */}
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
|
||||
{t('auth_login.iflow_cookie_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
|
||||
{t('auth_login.iflow_cookie_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHint}>{t('auth_login.iflow_cookie_hint')}</div>
|
||||
<div className={styles.cardHintSecondary}>
|
||||
{t('auth_login.iflow_cookie_key_hint')}
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formItemLabel}>{t('auth_login.iflow_cookie_label')}</label>
|
||||
<Input
|
||||
value={iflowCookie.cookie}
|
||||
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
|
||||
placeholder={t('auth_login.iflow_cookie_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{iflowCookie.error && (
|
||||
<div
|
||||
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
|
||||
>
|
||||
{iflowCookie.errorType === 'warning'
|
||||
? t('auth_login.iflow_cookie_status_duplicate')
|
||||
: t('auth_login.iflow_cookie_status_error')}{' '}
|
||||
{iflowCookie.error}
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
|
||||
<div className={styles.connectionBox}>
|
||||
<div className={styles.connectionLabel}>{t('auth_login.iflow_cookie_result_title')}</div>
|
||||
<div className={styles.keyValueList}>
|
||||
{iflowCookie.result.email && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_email')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.expired && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_expired')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.expired}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.saved_path && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_path')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.saved_path}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.type && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_type')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
|
||||
import type { AuthFileItem, Config } from '@/types';
|
||||
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import { parseTimestampMs } from '@/utils/timestamp';
|
||||
import {
|
||||
collectUsageDetailsWithEndpoint,
|
||||
normalizeAuthIndex,
|
||||
@@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
if (!logPath) return [];
|
||||
|
||||
const logTimestampMs = traceLogLine.timestamp
|
||||
? Date.parse(traceLogLine.timestamp)
|
||||
? parseTimestampMs(traceLogLine.timestamp)
|
||||
: Number.NaN;
|
||||
|
||||
// Step 1: filter by path match
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { apiClient } from './client';
|
||||
import type { AuthFilesResponse } from '@/types/authFile';
|
||||
import type { OAuthModelAliasEntry } from '@/types';
|
||||
import { parseTimestampMs } from '@/utils/timestamp';
|
||||
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
@@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => {
|
||||
if (Number.isFinite(asNumber)) {
|
||||
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
const parsed = parseTimestampMs(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'kimi'
|
||||
| 'qwen';
|
||||
| 'kimi';
|
||||
|
||||
export interface OAuthStartResponse {
|
||||
url: string;
|
||||
@@ -21,15 +20,6 @@ export interface OAuthCallbackResponse {
|
||||
status: 'ok';
|
||||
}
|
||||
|
||||
export interface IFlowCookieAuthResponse {
|
||||
status: 'ok' | 'error';
|
||||
error?: string;
|
||||
saved_path?: string;
|
||||
email?: string;
|
||||
expired?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
|
||||
'gemini-cli': 'gemini'
|
||||
@@ -60,9 +50,5 @@ export const oauthApi = {
|
||||
provider: callbackProvider,
|
||||
redirect_url: redirectUrl
|
||||
});
|
||||
},
|
||||
|
||||
/** iFlow cookie 认证 */
|
||||
iflowCookieAuth: (cookie: string) =>
|
||||
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,6 +94,12 @@ const normalizePrefix = (value: unknown): string | undefined => {
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeAuthIndex = (value: unknown): string | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
if (entry === undefined || entry === null) return null;
|
||||
const record = isRecord(entry) ? entry : null;
|
||||
@@ -104,12 +110,17 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
const headers = record ? normalizeHeaders(record.headers) : undefined;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
|
||||
return {
|
||||
const result: ApiKeyEntry = {
|
||||
apiKey: trimmed,
|
||||
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
|
||||
headers
|
||||
};
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
|
||||
@@ -146,6 +157,10 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
record?.excluded_models
|
||||
);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
|
||||
const cloakRaw = record?.cloak;
|
||||
if (isRecord(cloakRaw)) {
|
||||
@@ -204,6 +219,10 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (headers) config.headers = headers;
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -241,6 +260,10 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
|
||||
if (models.length) result.models = models;
|
||||
if (priority !== undefined) result.priority = Number(priority);
|
||||
if (testModel) result.testModel = String(testModel);
|
||||
const authIndex = normalizeAuthIndex(
|
||||
provider['auth-index'] ?? provider.authIndex ?? provider['auth_index']
|
||||
);
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
export type Theme = 'light' | 'white' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||
export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ru';
|
||||
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
|
||||
+1
-2
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'kimi'
|
||||
| 'qwen';
|
||||
| 'kimi';
|
||||
|
||||
// OAuth 流程状态
|
||||
export interface OAuthFlow {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ApiKeyEntry {
|
||||
apiKey: string;
|
||||
proxyUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface CloakConfig {
|
||||
@@ -31,6 +32,7 @@ export interface GeminiKeyConfig {
|
||||
models?: ModelAlias[];
|
||||
headers?: Record<string, string>;
|
||||
excludedModels?: string[];
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface ProviderKeyConfig {
|
||||
@@ -44,6 +46,7 @@ export interface ProviderKeyConfig {
|
||||
models?: ModelAlias[];
|
||||
excludedModels?: string[];
|
||||
cloak?: CloakConfig;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIProviderConfig {
|
||||
@@ -55,5 +58,6 @@ export interface OpenAIProviderConfig {
|
||||
models?: ModelAlias[];
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
authIndex?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type SourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
identityKey?: string;
|
||||
};
|
||||
|
||||
export type CredentialInfo = {
|
||||
|
||||
@@ -77,6 +77,8 @@ export type VisualConfigValues = {
|
||||
quotaSwitchPreviewModel: boolean;
|
||||
quotaAntigravityCredits: boolean;
|
||||
routingStrategy: 'round-robin' | 'fill-first';
|
||||
routingSessionAffinity: boolean;
|
||||
routingSessionAffinityTTL: string;
|
||||
wsAuth: boolean;
|
||||
payloadDefaultRules: PayloadRule[];
|
||||
payloadDefaultRawRules: PayloadRule[];
|
||||
@@ -117,6 +119,8 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
quotaSwitchPreviewModel: true,
|
||||
quotaAntigravityCredits: true,
|
||||
routingStrategy: 'round-robin',
|
||||
routingSessionAffinity: false,
|
||||
routingSessionAffinityTTL: '',
|
||||
wsAuth: false,
|
||||
payloadDefaultRules: [],
|
||||
payloadDefaultRawRules: [],
|
||||
|
||||
@@ -40,9 +40,10 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
|
||||
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
|
||||
|
||||
// 语言配置
|
||||
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
|
||||
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'zh-TW', 'en', 'ru'] as const);
|
||||
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
|
||||
'zh-CN': 'language.chinese',
|
||||
'zh-TW': 'language.chinese_tw',
|
||||
en: 'language.english',
|
||||
ru: 'language.russian'
|
||||
};
|
||||
@@ -57,16 +58,14 @@ export const OAUTH_CARD_IDS = [
|
||||
'anthropic-oauth-card',
|
||||
'antigravity-oauth-card',
|
||||
'gemini-cli-oauth-card',
|
||||
'kimi-oauth-card',
|
||||
'qwen-oauth-card'
|
||||
'kimi-oauth-card'
|
||||
];
|
||||
export const OAUTH_PROVIDERS = {
|
||||
CODEX: 'codex',
|
||||
ANTHROPIC: 'anthropic',
|
||||
ANTIGRAVITY: 'antigravity',
|
||||
GEMINI_CLI: 'gemini-cli',
|
||||
KIMI: 'kimi',
|
||||
QWEN: 'qwen'
|
||||
KIMI: 'kimi'
|
||||
} as const;
|
||||
|
||||
// API 端点
|
||||
|
||||
+4
-2
@@ -1,3 +1,5 @@
|
||||
import { parseTimestamp } from './timestamp';
|
||||
|
||||
/**
|
||||
* 格式化工具函数
|
||||
* 从原项目 src/utils/string.js 迁移
|
||||
@@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string {
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date, locale?: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date;
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return 'Invalid Date';
|
||||
@@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
|
||||
const asNumber = typeof value === 'number' ? value : Number(value);
|
||||
const date = (() => {
|
||||
if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) {
|
||||
return new Date(String(value));
|
||||
return parseTimestamp(value) ?? new Date(String(value));
|
||||
}
|
||||
|
||||
const abs = Math.abs(asNumber);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
|
||||
|
||||
const TRADITIONAL_CHINESE_PREFIXES = ['zh-tw', 'zh-hk', 'zh-mo', 'zh-hant'] as const;
|
||||
|
||||
export const isSupportedLanguage = (value: string): value is Language =>
|
||||
SUPPORTED_LANGUAGES.includes(value as Language);
|
||||
|
||||
@@ -40,6 +42,7 @@ const getBrowserLanguage = (): Language => {
|
||||
}
|
||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||
const lower = raw.toLowerCase();
|
||||
if (TRADITIONAL_CHINESE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return 'zh-TW';
|
||||
if (lower.startsWith('zh')) return 'zh-CN';
|
||||
if (lower.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
|
||||
+109
-23
@@ -10,20 +10,64 @@ export interface SourceInfoMapInput {
|
||||
openaiCompatibility?: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, SourceInfo> {
|
||||
const map = new Map<string, SourceInfo>();
|
||||
type SourceInfoEntry = Required<Pick<SourceInfo, 'displayName' | 'type' | 'identityKey'>>;
|
||||
|
||||
const registerSource = (sourceId: string, displayName: string, type: string) => {
|
||||
if (!sourceId || !displayName || map.has(sourceId)) return;
|
||||
map.set(sourceId, { displayName, type });
|
||||
};
|
||||
export interface SourceInfoMap {
|
||||
byAuthIndex: Map<string, SourceInfoEntry | null>;
|
||||
bySource: Map<string, SourceInfoEntry | null>;
|
||||
}
|
||||
|
||||
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
|
||||
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
|
||||
const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`;
|
||||
|
||||
const registerIdentity = (
|
||||
map: Map<string, SourceInfoEntry | null>,
|
||||
key: string | null | undefined,
|
||||
entry: SourceInfoEntry
|
||||
) => {
|
||||
if (!key) return;
|
||||
|
||||
const existing = map.get(key);
|
||||
if (existing === undefined) {
|
||||
map.set(key, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.identityKey === entry.identityKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.set(key, null);
|
||||
};
|
||||
|
||||
const formatRawSourceDisplayName = (source: string) => {
|
||||
if (!source) return '-';
|
||||
return source.startsWith('t:') ? source.slice(2) : source;
|
||||
};
|
||||
|
||||
export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap {
|
||||
const byAuthIndex = new Map<string, SourceInfoEntry | null>();
|
||||
const bySource = new Map<string, SourceInfoEntry | null>();
|
||||
|
||||
const registerProvider = (
|
||||
entry: SourceInfoEntry,
|
||||
authIndices: Array<unknown>,
|
||||
candidates: Iterable<string>
|
||||
) => {
|
||||
authIndices.forEach((authIndex) => {
|
||||
registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry);
|
||||
});
|
||||
|
||||
Array.from(candidates).forEach((candidate) => {
|
||||
registerIdentity(bySource, candidate, entry);
|
||||
});
|
||||
};
|
||||
|
||||
const providers: Array<{
|
||||
items: Array<{ apiKey?: string; prefix?: string }>;
|
||||
items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>;
|
||||
type: string;
|
||||
label: string;
|
||||
}> = [
|
||||
@@ -35,49 +79,91 @@ export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, Sourc
|
||||
|
||||
providers.forEach(({ items, type, label }) => {
|
||||
items.forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `${label} #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
type,
|
||||
registerProvider(
|
||||
{
|
||||
displayName: item.prefix?.trim() || `${label} #${index + 1}`,
|
||||
type,
|
||||
identityKey: buildProviderIdentityKey(type, index),
|
||||
},
|
||||
[item.authIndex],
|
||||
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// OpenAI 特殊处理:多 apiKeyEntries
|
||||
(input.openaiCompatibility || []).forEach((provider, providerIndex) => {
|
||||
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
const candidates = new Set<string>();
|
||||
const authIndices: Array<unknown> = [provider.authIndex];
|
||||
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
authIndices.push(entry.authIndex);
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
registerCandidates(displayName, 'openai', Array.from(candidates));
|
||||
|
||||
registerProvider(
|
||||
{
|
||||
displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`,
|
||||
type: 'openai',
|
||||
identityKey: buildProviderIdentityKey('openai', providerIndex),
|
||||
},
|
||||
authIndices,
|
||||
candidates
|
||||
);
|
||||
});
|
||||
|
||||
return map;
|
||||
return { byAuthIndex, bySource };
|
||||
}
|
||||
|
||||
export function resolveSourceDisplay(
|
||||
sourceRaw: string,
|
||||
authIndex: unknown,
|
||||
sourceInfoMap: Map<string, SourceInfo>,
|
||||
sourceInfoMap: SourceInfoMap,
|
||||
authFileMap: Map<string, CredentialInfo>
|
||||
): SourceInfo {
|
||||
const source = sourceRaw.trim();
|
||||
const matched = sourceInfoMap.get(source);
|
||||
if (matched) return matched;
|
||||
|
||||
const authIndexKey = normalizeAuthIndex(authIndex);
|
||||
|
||||
if (authIndexKey) {
|
||||
const matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey);
|
||||
if (matchedByAuthIndex) {
|
||||
return matchedByAuthIndex;
|
||||
}
|
||||
|
||||
const authInfo = authFileMap.get(authIndexKey);
|
||||
if (authInfo) {
|
||||
return { displayName: authInfo.name || authIndexKey, type: authInfo.type };
|
||||
return {
|
||||
displayName: authInfo.name || authIndexKey,
|
||||
type: authInfo.type,
|
||||
identityKey: `auth:${authIndexKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null;
|
||||
if (matchedBySource) {
|
||||
return matchedBySource;
|
||||
}
|
||||
|
||||
if (source) {
|
||||
return {
|
||||
displayName: formatRawSourceDisplayName(source),
|
||||
type: '',
|
||||
identityKey: `source:${source}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (authIndexKey) {
|
||||
return {
|
||||
displayName: authIndexKey,
|
||||
type: '',
|
||||
identityKey: `auth:${authIndexKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
|
||||
displayName: '-',
|
||||
type: '',
|
||||
identityKey: 'source:-',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const RFC3339_HIGH_PRECISION_REGEX =
|
||||
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i;
|
||||
|
||||
/**
|
||||
* Some browsers mis-handle RFC3339 timestamps that include sub-millisecond
|
||||
* precision. Normalize them to millisecond precision before parsing.
|
||||
*/
|
||||
export function normalizeTimestampForDateParse(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX);
|
||||
if (!match) return trimmed;
|
||||
|
||||
const [, base, , fractionDigits = '', timezone = ''] = match;
|
||||
if (fractionDigits.length <= 3) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${base}.${fractionDigits.slice(0, 3)}${timezone}`;
|
||||
}
|
||||
|
||||
export function parseTimestampMs(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const normalized = normalizeTimestampForDateParse(trimmed);
|
||||
const normalizedParsed = Date.parse(normalized);
|
||||
if (!Number.isNaN(normalizedParsed)) {
|
||||
return normalizedParsed;
|
||||
}
|
||||
|
||||
if (normalized !== trimmed) {
|
||||
const originalParsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(originalParsed)) {
|
||||
return originalParsed;
|
||||
}
|
||||
}
|
||||
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
export function parseTimestamp(value: unknown): Date | null {
|
||||
const timestampMs = parseTimestampMs(value);
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return null;
|
||||
}
|
||||
return new Date(timestampMs);
|
||||
}
|
||||
+25
-16
@@ -13,6 +13,7 @@ import {
|
||||
finalizeLatencyStats,
|
||||
} from './usage/latency';
|
||||
import { maskApiKey } from './format';
|
||||
import { parseTimestampMs } from './timestamp';
|
||||
|
||||
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
|
||||
export {
|
||||
@@ -55,7 +56,7 @@ 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;
|
||||
@@ -195,7 +196,7 @@ export function filterUsageByTimeRange<T>(
|
||||
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;
|
||||
}
|
||||
@@ -545,13 +546,17 @@ 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,
|
||||
@@ -618,13 +623,17 @@ 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,
|
||||
@@ -721,7 +730,7 @@ export function calculateRecentPerMinuteRates(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
@@ -1131,7 +1140,7 @@ export function buildHourlySeriesByModel(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1190,7 +1199,7 @@ export function buildDailySeriesByModel(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1393,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
|
||||
@@ -1416,7 +1425,7 @@ export function calculateStatusBarData(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
@@ -1524,7 +1533,7 @@ export function calculateServiceHealthData(usageDetails: UsageDetail[]): Service
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
@@ -1734,7 +1743,7 @@ export function buildHourlyTokenBreakdown(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1776,7 +1785,7 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
@@ -1853,7 +1862,7 @@ export function buildHourlyCostSeries(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1888,7 +1897,7 @@ export function buildDailyCostSeries(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
+36
-2
@@ -1,6 +1,8 @@
|
||||
import type { UsageDetail } from '@/utils/usage';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
|
||||
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
|
||||
export type UsageDetailsByAuthIndex = Map<string, UsageDetail[]>;
|
||||
|
||||
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
|
||||
|
||||
@@ -22,15 +24,47 @@ export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDet
|
||||
return map;
|
||||
}
|
||||
|
||||
export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex {
|
||||
const map: UsageDetailsByAuthIndex = new Map();
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const authIndexKey = normalizeAuthIndex(detail.auth_index);
|
||||
if (!authIndexKey) return;
|
||||
|
||||
const bucket = map.get(authIndexKey);
|
||||
if (bucket) {
|
||||
bucket.push(detail);
|
||||
} else {
|
||||
map.set(authIndexKey, [detail]);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function collectUsageDetailsForCandidates(
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
candidates: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
return collectUsageDetailsForKeys(usageDetailsBySource, candidates);
|
||||
}
|
||||
|
||||
export function collectUsageDetailsForAuthIndices(
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex,
|
||||
authIndices: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices);
|
||||
}
|
||||
|
||||
function collectUsageDetailsForKeys(
|
||||
usageDetailsByKey: Map<string, UsageDetail[]>,
|
||||
keys: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
let firstDetails: UsageDetail[] | null = null;
|
||||
let merged: UsageDetail[] | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const details = usageDetailsBySource.get(candidate);
|
||||
for (const key of keys) {
|
||||
const details = usageDetailsByKey.get(key);
|
||||
if (!details || details.length === 0) continue;
|
||||
|
||||
if (!firstDetails) {
|
||||
|
||||
Reference in New Issue
Block a user