Compare commits

...

10 Commits

32 changed files with 2298 additions and 676 deletions
+1
View File
@@ -23,6 +23,7 @@ skills
# Editor directories and files
settings.local.json
.codex
.vscode/*
!.vscode/extensions.json
.idea
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface ClaudeSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function ClaudeSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function ClaudeSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function ClaudeSection({
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.claude_empty_title')}
emptyDescription={t('ai_providers.claude_empty_desc')}
onEdit={onEdit}
@@ -101,12 +103,16 @@ export function ClaudeSection({
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
renderContent={(item, index) => {
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconCodex from '@/assets/icons/codex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface CodexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function CodexSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function CodexSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function CodexSection({
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.codex_empty_title')}
emptyDescription={t('ai_providers.codex_empty_desc')}
onEdit={onEdit}
@@ -101,12 +103,16 @@ export function CodexSection({
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
renderContent={(item, index) => {
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface GeminiSectionProps {
configs: GeminiKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function GeminiSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function GeminiSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function GeminiSection({
<ProviderList<GeminiKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.gemini_empty_title')}
emptyDescription={t('ai_providers.gemini_empty_desc')}
onEdit={onEdit}
@@ -102,11 +104,15 @@ export function GeminiSection({
/>
)}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -7,21 +7,24 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { OpenAIProviderConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
import {
collectOpenAIProviderUsageDetails,
getOpenAIEntryKey,
getOpenAIProviderKey,
getOpenAIProviderStats,
getStatsForIdentity,
} from '../utils';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -35,6 +38,7 @@ export function OpenAISection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -49,21 +53,22 @@ export function OpenAISection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => {
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
});
const filteredDetails = sourceIds.size
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails));
configs.forEach((provider, index) => {
const providerKey = getOpenAIProviderKey(provider, index);
cache.set(
providerKey,
calculateStatusBarData(
collectOpenAIProviderUsageDetails(
provider,
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -87,17 +92,18 @@ export function OpenAISection({
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(_, index) => `openai-provider-${index}`}
keyField={(item, index) => getOpenAIProviderKey(item, index)}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
renderContent={(item, index) => {
const stats = getOpenAIProviderStats(item, keyStats);
const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -134,9 +140,15 @@ export function OpenAISection({
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats);
const entryStats = getStatsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
keyStats
);
return (
<div key={entryIndex} className={styles.apiKeyEntryCard}>
<div
key={getOpenAIEntryKey(entry, entryIndex)}
className={styles.apiKeyEntryCard}
>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconVertex from '@/assets/icons/vertex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function VertexSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function VertexSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function VertexSection({
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
@@ -102,11 +104,15 @@ export function VertexSection({
/>
)}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
+172 -16
View File
@@ -1,5 +1,23 @@
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
import type {
AmpcodeConfig,
AmpcodeModelMapping,
AmpcodeUpstreamApiKeyMapping,
ApiKeyEntry,
OpenAIProviderConfig,
} from '@/types';
import {
buildCandidateUsageSourceIds,
normalizeAuthIndex,
type KeyStatBucket,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForAuthIndices,
collectUsageDetailsForCandidates,
type UsageDetailsByAuthIndex,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*';
@@ -109,25 +127,94 @@ export const getStatsBySource = (
return { success, failure };
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats,
providerPrefix?: string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
type UsageIdentity = {
authIndex?: unknown;
apiKey?: string;
prefix?: string;
};
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
(apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
export const getStatsForIdentity = (
identity: UsageIdentity,
keyStats: KeyStats
): KeyStatBucket => {
const authIndexKey = normalizeAuthIndex(identity.authIndex);
if (authIndexKey) {
const stats = keyStats.byAuthIndex?.[authIndexKey];
if (stats) {
return { success: stats.success, failure: stats.failure };
}
}
return getStatsBySource(identity.apiKey ?? '', keyStats, identity.prefix);
};
export const collectUsageDetailsForIdentity = (
identity: UsageIdentity,
usageDetailsBySource: UsageDetailsBySource,
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
): UsageDetail[] => {
const authIndexKey = normalizeAuthIndex(identity.authIndex);
if (authIndexKey) {
const details = collectUsageDetailsForAuthIndices(usageDetailsByAuthIndex, [authIndexKey]);
if (details.length > 0) {
return details;
}
}
const candidates = buildCandidateUsageSourceIds({
apiKey: identity.apiKey,
prefix: identity.prefix,
});
if (!candidates.length) {
return [];
}
return collectUsageDetailsForCandidates(usageDetailsBySource, candidates);
};
const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
groups.forEach((details) => {
if (!details.length) return;
if (!firstDetails) {
firstDetails = details;
return;
}
if (!merged) {
merged = [...firstDetails];
}
merged.push(...details);
});
return merged ?? firstDetails ?? [];
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
provider: OpenAIProviderConfig,
keyStats: KeyStats
): KeyStatBucket => {
let success = 0;
let failure = 0;
sourceIds.forEach((id) => {
const stats = bySource[id];
if (!stats) return;
if (!provider.apiKeyEntries?.length) {
const stats = getStatsForIdentity(
{ authIndex: provider.authIndex, prefix: provider.prefix },
keyStats
);
return { success: stats.success, failure: stats.failure };
}
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
const prefixStats = getStatsBySource('', keyStats, provider.prefix);
success += prefixStats.success;
failure += prefixStats.failure;
}
provider.apiKeyEntries.forEach((entry) => {
const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats);
success += stats.success;
failure += stats.failure;
});
@@ -135,6 +222,75 @@ export const getOpenAIProviderStats = (
return { success, failure };
};
export const collectOpenAIProviderUsageDetails = (
provider: OpenAIProviderConfig,
usageDetailsBySource: UsageDetailsBySource,
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
): UsageDetail[] => {
if (!provider.apiKeyEntries?.length) {
return collectUsageDetailsForIdentity(
{ authIndex: provider.authIndex, prefix: provider.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
);
}
const groups: UsageDetail[][] = [];
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
groups.push(
collectUsageDetailsForIdentity(
{ prefix: provider.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
);
}
provider.apiKeyEntries.forEach((entry) => {
groups.push(
collectUsageDetailsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
usageDetailsBySource,
usageDetailsByAuthIndex
)
);
});
return mergeUsageDetails(groups);
};
export const getProviderConfigKey = (
config: {
authIndex?: unknown;
apiKey?: string;
baseUrl?: string;
proxyUrl?: string;
},
index: number
): string => {
const authIndexKey = normalizeAuthIndex(config.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${config.apiKey ?? ''}::${config.baseUrl ?? ''}::${config.proxyUrl ?? ''}::${index}`;
};
export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => {
const authIndexKey = normalizeAuthIndex(provider.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${provider.name}::${provider.baseUrl}::${provider.prefix ?? ''}::${index}`;
};
export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => {
const authIndexKey = normalizeAuthIndex(entry.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${entry.apiKey}::${entry.proxyUrl ?? ''}::${index}`;
};
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
apiKey: input?.apiKey ?? '',
proxyUrl: input?.proxyUrl ?? '',
+106 -248
View File
@@ -1,16 +1,12 @@
import { useMemo, useState, useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import {
collectUsageDetails,
buildCandidateUsageSourceIds,
formatCompactNumber,
normalizeAuthIndex
} from '@/utils/usage';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { collectUsageDetails, formatCompactNumber, normalizeAuthIndex } from '@/utils/usage';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
@@ -34,11 +30,6 @@ interface CredentialRow {
successRate: number;
}
interface CredentialBucket {
success: number;
failure: number;
}
export function CredentialStatsCard({
usage,
loading,
@@ -51,223 +42,86 @@ export function CredentialStatsCard({
const { t } = useTranslation();
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
// Fetch auth files for auth_index-based matching
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const key = normalizeAuthIndex(rawAuthIndex);
if (key) {
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
}
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
});
setAuthFileMap(map);
})
.catch(() => {});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
// Auth files are used purely for name resolution of unmatched source IDs.
const sourceInfoMap = useMemo(
() =>
buildSourceInfoMap({
geminiApiKeys: geminiKeys,
claudeApiKeys: claudeConfigs,
codexApiKeys: codexConfigs,
vertexApiKeys: vertexConfigs,
openaiCompatibility: openaiProviders,
}),
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
);
const rows = useMemo((): CredentialRow[] => {
if (!usage) return [];
const details = collectUsageDetails(usage);
const bySource: Record<string, CredentialBucket> = {};
const result: CredentialRow[] = [];
const consumedSourceIds = new Set<string>();
const authIndexToRowIndex = new Map<string, number>();
const sourceToAuthIndex = new Map<string, string>();
const sourceToAuthFile = new Map<string, CredentialInfo>();
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
details.forEach((detail) => {
const authIdx = normalizeAuthIndex(detail.auth_index);
const source = detail.source;
const isFailed = detail.failed === true;
const rowMap = new Map<string, CredentialRow>();
if (!source) {
if (!authIdx) return;
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
if (isFailed) {
fallback.failure += 1;
} else {
fallback.success += 1;
}
fallbackByAuthIndex.set(authIdx, fallback);
return;
}
collectUsageDetails(usage).forEach((detail) => {
const sourceInfo = resolveSourceDisplay(
detail.source ?? '',
detail.auth_index,
sourceInfoMap,
authFileMap
);
const key = sourceInfo.identityKey ?? sourceInfo.displayName;
const row =
rowMap.get(key) ??
({
key,
displayName: sourceInfo.displayName,
type: sourceInfo.type,
success: 0,
failure: 0,
total: 0,
successRate: 100,
} satisfies CredentialRow);
const bucket = bySource[source] ?? { success: 0, failure: 0 };
if (isFailed) {
bucket.failure += 1;
if (detail.failed === true) {
row.failure += 1;
} else {
bucket.success += 1;
row.success += 1;
}
bySource[source] = bucket;
if (authIdx && !sourceToAuthIndex.has(source)) {
sourceToAuthIndex.set(source, authIdx);
}
if (authIdx && !sourceToAuthFile.has(source)) {
const mapped = authFileMap.get(authIdx);
if (mapped) sourceToAuthFile.set(source, mapped);
}
row.total = row.success + row.failure;
row.successRate = row.total > 0 ? (row.success / row.total) * 100 : 100;
rowMap.set(key, row);
});
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
const target = result[index];
if (!target) return;
target.success += bucket.success;
target.failure += bucket.failure;
target.total = target.success + target.failure;
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
};
// Aggregate all candidate source IDs for one provider config into a single row
const addConfigRow = (
apiKey: string,
prefix: string | undefined,
name: string,
type: string,
rowKey: string,
) => {
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: rowKey,
displayName: name,
type,
success,
failure,
total,
successRate: (success / total) * 100,
});
}
};
// Provider rows — one row per config, stats merged across all its candidate source IDs
geminiKeys.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
claudeConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
codexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
vertexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
openaiProviders.forEach((provider, providerIndex) => {
const prefix = provider.prefix;
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: `openai:${providerIndex}`,
displayName,
type: 'openai',
success,
failure,
total,
successRate: (success / total) * 100,
});
}
});
// Remaining unmatched bySource entries — resolve name from auth files if possible
Object.entries(bySource).forEach(([key, bucket]) => {
if (consumedSourceIds.has(key)) return;
const total = bucket.success + bucket.failure;
const authFile = sourceToAuthFile.get(key);
const row = {
key,
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
type: authFile?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
};
const rowIndex = result.push(row) - 1;
const authIdx = sourceToAuthIndex.get(key);
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
authIndexToRowIndex.set(authIdx, rowIndex);
}
});
// Include requests that have auth_index but missing source.
fallbackByAuthIndex.forEach((bucket, authIdx) => {
if (bucket.success + bucket.failure === 0) return;
const mapped = authFileMap.get(authIdx);
let targetRowIndex = authIndexToRowIndex.get(authIdx);
if (targetRowIndex === undefined && mapped) {
const matchedIndex = result.findIndex(
(row) => row.displayName === mapped.name && row.type === mapped.type
);
if (matchedIndex >= 0) {
targetRowIndex = matchedIndex;
authIndexToRowIndex.set(authIdx, matchedIndex);
}
}
if (targetRowIndex !== undefined) {
mergeBucketToRow(targetRowIndex, bucket);
return;
}
const total = bucket.success + bucket.failure;
const rowIndex = result.push({
key: `auth:${authIdx}`,
displayName: mapped?.name || authIdx,
type: mapped?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: (bucket.success / total) * 100
}) - 1;
authIndexToRowIndex.set(authIdx, rowIndex);
});
return result.sort((a, b) => b.total - a.total);
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return Array.from(rowMap.values()).sort((a, b) => b.total - a.total);
}, [authFileMap, sourceInfoMap, usage]);
return (
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
@@ -275,51 +129,55 @@ export function CredentialStatsCard({
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && (
<span className={styles.credentialType}>{row.type}</span>
)}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && <span className={styles.credentialType}>{row.type}</span>}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(
<span className={styles.statSuccess}>
{row.success.toLocaleString()}
</span>{' '}
<span className={styles.statFailure}>
{row.failure.toLocaleString()}
</span>
)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
@@ -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;
+2 -1
View File
@@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import type { AuthFileItem } from '@/types';
import { parseTimestamp } from '@/utils/timestamp';
import {
normalizeAuthIndex,
normalizeUsageSourceId,
@@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => {
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(raw));
: parseTimestamp(raw) ?? new Date(String(raw));
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
+2
View File
@@ -5,6 +5,7 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import zhTW from './locales/zh-TW.json';
import en from './locales/en.json';
import ru from './locales/ru.json';
import { getInitialLanguage } from '@/utils/language';
@@ -12,6 +13,7 @@ import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
en: { translation: en },
ru: { translation: ru }
},
+1 -28
View File
@@ -891,17 +891,6 @@
"kimi_oauth_status_error": "Authentication failed:",
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
"kimi_oauth_polling_error": "Failed to check authentication status:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Start Qwen Login",
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
"qwen_oauth_url_label": "Authorization URL:",
"qwen_open_link": "Open Link",
"qwen_copy_link": "Copy Link",
"qwen_oauth_status_waiting": "Waiting for authentication...",
"qwen_oauth_status_success": "Authentication successful!",
"qwen_oauth_status_error": "Authentication failed:",
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
"qwen_oauth_polling_error": "Failed to check authentication status:",
"oauth_callback_label": "Callback URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.",
@@ -924,23 +913,6 @@
"iflow_oauth_status_error": "Authentication failed:",
"iflow_oauth_start_error": "Failed to start iFlow OAuth:",
"iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
"iflow_cookie_button": "Submit Cookie Login",
"iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.",
"iflow_cookie_status_error": "Cookie login failed:",
"iflow_cookie_status_duplicate": "Duplicate config:",
"iflow_cookie_start_error": "Failed to submit cookie login:",
"iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.",
"iflow_cookie_required": "Please provide the Cookie value first.",
"iflow_cookie_result_title": "Cookie Login Result",
"iflow_cookie_result_email": "Account",
"iflow_cookie_result_expired": "Expires At",
"iflow_cookie_result_path": "Saved Path",
"iflow_cookie_result_type": "Type",
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost."
},
"usage_stats": {
@@ -1487,6 +1459,7 @@
"language": {
"switch": "Language",
"chinese": "中文",
"chinese_tw": "Traditional Chinese (Taiwan)",
"english": "English",
"russian": "Русский"
},
+1 -28
View File
@@ -888,17 +888,6 @@
"kimi_oauth_status_error": "Ошибка аутентификации:",
"kimi_oauth_start_error": "Не удалось запустить Kimi OAuth:",
"kimi_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Начать вход Qwen",
"qwen_oauth_hint": "Выполните вход в сервис Qwen через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.",
"qwen_oauth_url_label": "URL авторизации:",
"qwen_open_link": "Открыть ссылку",
"qwen_copy_link": "Скопировать ссылку",
"qwen_oauth_status_waiting": "Ожидание аутентификации...",
"qwen_oauth_status_success": "Аутентификация успешна!",
"qwen_oauth_status_error": "Ошибка аутентификации:",
"qwen_oauth_start_error": "Не удалось запустить Qwen OAuth:",
"qwen_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"oauth_callback_label": "Callback URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "Режим удалённого браузера: после перенаправления провайдера на http://localhost:... скопируйте полный URL и отправьте его здесь.",
@@ -921,23 +910,6 @@
"iflow_oauth_status_error": "Ошибка аутентификации:",
"iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:",
"iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"iflow_cookie_title": "Вход iFlow по cookie",
"iflow_cookie_label": "Значение cookie:",
"iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=",
"iflow_cookie_hint": "Отправьте существующий cookie, чтобы завершить вход без открытия ссылки авторизации; файл учётных данных будет сохранён автоматически.",
"iflow_cookie_key_hint": "Примечание: сначала создайте ключ на платформе.",
"iflow_cookie_button": "Отправить вход по cookie",
"iflow_cookie_status_success": "Вход по cookie выполнен, учётные данные сохранены.",
"iflow_cookie_status_error": "Ошибка входа по cookie:",
"iflow_cookie_status_duplicate": "Дублирующая конфигурация:",
"iflow_cookie_start_error": "Не удалось отправить вход по cookie:",
"iflow_cookie_config_duplicate": "Такая конфигурация уже существует. Удалите файл и повторите, если хотите перезаписать.",
"iflow_cookie_required": "Сначала укажите значение cookie.",
"iflow_cookie_result_title": "Результат входа по cookie",
"iflow_cookie_result_email": "Аккаунт",
"iflow_cookie_result_expired": "Истекает",
"iflow_cookie_result_path": "Путь сохранения",
"iflow_cookie_result_type": "Тип",
"remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost."
},
"usage_stats": {
@@ -1486,6 +1458,7 @@
"language": {
"switch": "Язык",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
+1 -28
View File
@@ -891,17 +891,6 @@
"kimi_oauth_status_error": "认证失败:",
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
"kimi_oauth_polling_error": "检查认证状态失败:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "开始 Qwen 登录",
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
"qwen_oauth_url_label": "授权链接:",
"qwen_open_link": "打开链接",
"qwen_copy_link": "复制链接",
"qwen_oauth_status_waiting": "等待认证中...",
"qwen_oauth_status_success": "认证成功!",
"qwen_oauth_status_error": "认证失败:",
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
"qwen_oauth_polling_error": "检查认证状态失败:",
"oauth_callback_label": "回调 URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。",
@@ -924,23 +913,6 @@
"iflow_oauth_status_error": "认证失败:",
"iflow_oauth_start_error": "启动 iFlow OAuth 失败:",
"iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
"iflow_cookie_button": "提交 Cookie 登录",
"iflow_cookie_status_success": "Cookie 登录成功,凭据已保存。",
"iflow_cookie_status_error": "Cookie 登录失败:",
"iflow_cookie_status_duplicate": "配置文件重复:",
"iflow_cookie_start_error": "提交 Cookie 登录失败:",
"iflow_cookie_config_duplicate": "检测到配置文件已存在(重复),如需重新保存请先删除原文件后重试。",
"iflow_cookie_required": "请先填写 Cookie 内容",
"iflow_cookie_result_title": "Cookie 登录结果",
"iflow_cookie_result_email": "账号",
"iflow_cookie_result_expired": "过期时间",
"iflow_cookie_result_path": "保存路径",
"iflow_cookie_result_type": "类型",
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问"
},
"usage_stats": {
@@ -1487,6 +1459,7 @@
"language": {
"switch": "语言",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -20,7 +20,7 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
@@ -71,6 +71,10 @@ export function AiProvidersPage() {
() => indexUsageDetailsBySource(usageDetails),
[usageDetails]
);
const usageDetailsByAuthIndex = useMemo(
() => indexUsageDetailsByAuthIndex(usageDetails),
[usageDetails]
);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
@@ -378,6 +382,7 @@ export function AiProvidersPage() {
configs={geminiKeys}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -393,6 +398,7 @@ export function AiProvidersPage() {
configs={codexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -408,6 +414,7 @@ export function AiProvidersPage() {
configs={claudeConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -423,6 +430,7 @@ export function AiProvidersPage() {
configs={vertexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -448,6 +456,7 @@ export function AiProvidersPage() {
configs={openaiProviders}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
+2 -128
View File
@@ -4,7 +4,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import { copyToClipboard } from '@/utils/clipboard';
import styles from './OAuthPage.module.scss';
@@ -14,8 +14,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState {
@@ -32,14 +30,6 @@ interface ProviderState {
callbackError?: string;
}
interface IFlowCookieState {
cookie: string;
loading: boolean;
result?: IFlowCookieAuthResponse;
error?: string;
errorType?: 'error' | 'warning';
}
interface VertexImportResult {
projectId?: string;
email?: string;
@@ -76,8 +66,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } }
];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
@@ -94,7 +83,6 @@ export function OAuthPage() {
const { showNotification } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
@@ -235,49 +223,6 @@ export function OAuthPage() {
}
};
const submitIflowCookie = async () => {
const cookie = iflowCookie.cookie.trim();
if (!cookie) {
showNotification(t('auth_login.iflow_cookie_required'), 'warning');
return;
}
setIflowCookie((prev) => ({
...prev,
loading: true,
error: undefined,
errorType: undefined,
result: undefined
}));
try {
const res = await oauthApi.iflowCookieAuth(cookie);
if (res.status === 'ok') {
setIflowCookie((prev) => ({ ...prev, loading: false, result: res }));
showNotification(t('auth_login.iflow_cookie_status_success'), 'success');
} else {
setIflowCookie((prev) => ({
...prev,
loading: false,
error: res.error,
errorType: 'error'
}));
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
}
} catch (err: unknown) {
if (getErrorStatus(err) === 409) {
const message = t('auth_login.iflow_cookie_config_duplicate');
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
showNotification(message, 'warning');
return;
}
const message = getErrorMessage(err);
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
showNotification(
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
'error'
);
}
};
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
@@ -542,77 +487,6 @@ export function OAuthPage() {
)}
</div>
</Card>
{/* iFlow Cookie 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
{t('auth_login.iflow_cookie_title')}
</span>
}
extra={
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
{t('auth_login.iflow_cookie_button')}
</Button>
}
>
<div className={styles.cardContent}>
<div className={styles.cardHint}>{t('auth_login.iflow_cookie_hint')}</div>
<div className={styles.cardHintSecondary}>
{t('auth_login.iflow_cookie_key_hint')}
</div>
<div className={styles.formItem}>
<label className={styles.formItemLabel}>{t('auth_login.iflow_cookie_label')}</label>
<Input
value={iflowCookie.cookie}
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
placeholder={t('auth_login.iflow_cookie_placeholder')}
/>
</div>
{iflowCookie.error && (
<div
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
>
{iflowCookie.errorType === 'warning'
? t('auth_login.iflow_cookie_status_duplicate')
: t('auth_login.iflow_cookie_status_error')}{' '}
{iflowCookie.error}
</div>
)}
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
<div className={styles.connectionBox}>
<div className={styles.connectionLabel}>{t('auth_login.iflow_cookie_result_title')}</div>
<div className={styles.keyValueList}>
{iflowCookie.result.email && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_email')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.email}</span>
</div>
)}
{iflowCookie.result.expired && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_expired')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.expired}</span>
</div>
)}
{iflowCookie.result.saved_path && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_path')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.saved_path}</span>
</div>
)}
{iflowCookie.result.type && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_type')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.type}</span>
</div>
)}
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
+2 -1
View File
@@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
import type { AuthFileItem, Config } from '@/types';
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { parseTimestampMs } from '@/utils/timestamp';
import {
collectUsageDetailsWithEndpoint,
normalizeAuthIndex,
@@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
if (!logPath) return [];
const logTimestampMs = traceLogLine.timestamp
? Date.parse(traceLogLine.timestamp)
? parseTimestampMs(traceLogLine.timestamp)
: Number.NaN;
// Step 1: filter by path match
+2 -1
View File
@@ -5,6 +5,7 @@
import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelAliasEntry } from '@/types';
import { parseTimestampMs } from '@/utils/timestamp';
type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
@@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => {
if (Number.isFinite(asNumber)) {
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
}
const parsed = Date.parse(trimmed);
const parsed = parseTimestampMs(trimmed);
if (!Number.isNaN(parsed)) {
return parsed;
}
+2 -16
View File
@@ -9,8 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
| 'kimi';
export interface OAuthStartResponse {
url: string;
@@ -21,15 +20,6 @@ export interface OAuthCallbackResponse {
status: 'ok';
}
export interface IFlowCookieAuthResponse {
status: 'ok' | 'error';
error?: string;
saved_path?: string;
email?: string;
expired?: string;
type?: string;
}
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
'gemini-cli': 'gemini'
@@ -60,9 +50,5 @@ export const oauthApi = {
provider: callbackProvider,
redirect_url: redirectUrl
});
},
/** iFlow cookie 认证 */
iflowCookieAuth: (cookie: string) =>
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
}
};
+24 -1
View File
@@ -94,6 +94,12 @@ const normalizePrefix = (value: unknown): string | undefined => {
return trimmed ? trimmed : undefined;
};
const normalizeAuthIndex = (value: unknown): string | undefined => {
if (value === undefined || value === null) return undefined;
const trimmed = String(value).trim();
return trimmed ? trimmed : undefined;
};
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
if (entry === undefined || entry === null) return null;
const record = isRecord(entry) ? entry : null;
@@ -104,12 +110,17 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
const headers = record ? normalizeHeaders(record.headers) : undefined;
const authIndex = normalizeAuthIndex(
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
);
return {
const result: ApiKeyEntry = {
apiKey: trimmed,
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
headers
};
if (authIndex) result.authIndex = authIndex;
return result;
};
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
@@ -146,6 +157,10 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
record?.excluded_models
);
if (excludedModels.length) config.excludedModels = excludedModels;
const authIndex = normalizeAuthIndex(
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
);
if (authIndex) config.authIndex = authIndex;
const cloakRaw = record?.cloak;
if (isRecord(cloakRaw)) {
@@ -204,6 +219,10 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
if (headers) config.headers = headers;
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
if (excludedModels.length) config.excludedModels = excludedModels;
const authIndex = normalizeAuthIndex(
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
);
if (authIndex) config.authIndex = authIndex;
return config;
};
@@ -241,6 +260,10 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
if (models.length) result.models = models;
if (priority !== undefined) result.priority = Number(priority);
if (testModel) result.testModel = String(testModel);
const authIndex = normalizeAuthIndex(
provider['auth-index'] ?? provider.authIndex ?? provider['auth_index']
);
if (authIndex) result.authIndex = authIndex;
return result;
};
+1 -1
View File
@@ -4,7 +4,7 @@
export type Theme = 'light' | 'white' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en' | 'ru';
export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ru';
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
+1 -2
View File
@@ -9,8 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
| 'kimi';
// OAuth 流程状态
export interface OAuthFlow {
+4
View File
@@ -14,6 +14,7 @@ export interface ApiKeyEntry {
apiKey: string;
proxyUrl?: string;
headers?: Record<string, string>;
authIndex?: string;
}
export interface CloakConfig {
@@ -31,6 +32,7 @@ export interface GeminiKeyConfig {
models?: ModelAlias[];
headers?: Record<string, string>;
excludedModels?: string[];
authIndex?: string;
}
export interface ProviderKeyConfig {
@@ -44,6 +46,7 @@ export interface ProviderKeyConfig {
models?: ModelAlias[];
excludedModels?: string[];
cloak?: CloakConfig;
authIndex?: string;
}
export interface OpenAIProviderConfig {
@@ -55,5 +58,6 @@ export interface OpenAIProviderConfig {
models?: ModelAlias[];
priority?: number;
testModel?: string;
authIndex?: string;
[key: string]: unknown;
}
+1
View File
@@ -1,6 +1,7 @@
export type SourceInfo = {
displayName: string;
type: string;
identityKey?: string;
};
export type CredentialInfo = {
+4 -5
View File
@@ -40,9 +40,10 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
// 语言配置
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'zh-TW', 'en', 'ru'] as const);
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
'zh-CN': 'language.chinese',
'zh-TW': 'language.chinese_tw',
en: 'language.english',
ru: 'language.russian'
};
@@ -57,16 +58,14 @@ export const OAUTH_CARD_IDS = [
'anthropic-oauth-card',
'antigravity-oauth-card',
'gemini-cli-oauth-card',
'kimi-oauth-card',
'qwen-oauth-card'
'kimi-oauth-card'
];
export const OAUTH_PROVIDERS = {
CODEX: 'codex',
ANTHROPIC: 'anthropic',
ANTIGRAVITY: 'antigravity',
GEMINI_CLI: 'gemini-cli',
KIMI: 'kimi',
QWEN: 'qwen'
KIMI: 'kimi'
} as const;
// API 端点
+4 -2
View File
@@ -1,3 +1,5 @@
import { parseTimestamp } from './timestamp';
/**
* 格式化工具函数
* 从原项目 src/utils/string.js 迁移
@@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string {
* 格式化日期时间
*/
export function formatDateTime(date: string | Date, locale?: string): string {
const d = typeof date === 'string' ? new Date(date) : date;
const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date;
if (isNaN(d.getTime())) {
return 'Invalid Date';
@@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
const asNumber = typeof value === 'number' ? value : Number(value);
const date = (() => {
if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) {
return new Date(String(value));
return parseTimestamp(value) ?? new Date(String(value));
}
const abs = Math.abs(asNumber);
+3
View File
@@ -1,6 +1,8 @@
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
const TRADITIONAL_CHINESE_PREFIXES = ['zh-tw', 'zh-hk', 'zh-mo', 'zh-hant'] as const;
export const isSupportedLanguage = (value: string): value is Language =>
SUPPORTED_LANGUAGES.includes(value as Language);
@@ -40,6 +42,7 @@ const getBrowserLanguage = (): Language => {
}
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
const lower = raw.toLowerCase();
if (TRADITIONAL_CHINESE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return 'zh-TW';
if (lower.startsWith('zh')) return 'zh-CN';
if (lower.startsWith('ru')) return 'ru';
return 'en';
+109 -23
View File
@@ -10,20 +10,64 @@ export interface SourceInfoMapInput {
openaiCompatibility?: OpenAIProviderConfig[];
}
export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, SourceInfo> {
const map = new Map<string, SourceInfo>();
type SourceInfoEntry = Required<Pick<SourceInfo, 'displayName' | 'type' | 'identityKey'>>;
const registerSource = (sourceId: string, displayName: string, type: string) => {
if (!sourceId || !displayName || map.has(sourceId)) return;
map.set(sourceId, { displayName, type });
};
export interface SourceInfoMap {
byAuthIndex: Map<string, SourceInfoEntry | null>;
bySource: Map<string, SourceInfoEntry | null>;
}
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`;
const registerIdentity = (
map: Map<string, SourceInfoEntry | null>,
key: string | null | undefined,
entry: SourceInfoEntry
) => {
if (!key) return;
const existing = map.get(key);
if (existing === undefined) {
map.set(key, entry);
return;
}
if (existing === null) {
return;
}
if (existing.identityKey === entry.identityKey) {
return;
}
map.set(key, null);
};
const formatRawSourceDisplayName = (source: string) => {
if (!source) return '-';
return source.startsWith('t:') ? source.slice(2) : source;
};
export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap {
const byAuthIndex = new Map<string, SourceInfoEntry | null>();
const bySource = new Map<string, SourceInfoEntry | null>();
const registerProvider = (
entry: SourceInfoEntry,
authIndices: Array<unknown>,
candidates: Iterable<string>
) => {
authIndices.forEach((authIndex) => {
registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry);
});
Array.from(candidates).forEach((candidate) => {
registerIdentity(bySource, candidate, entry);
});
};
const providers: Array<{
items: Array<{ apiKey?: string; prefix?: string }>;
items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>;
type: string;
label: string;
}> = [
@@ -35,49 +79,91 @@ export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, Sourc
providers.forEach(({ items, type, label }) => {
items.forEach((item, index) => {
const displayName = item.prefix?.trim() || `${label} #${index + 1}`;
registerCandidates(
displayName,
type,
registerProvider(
{
displayName: item.prefix?.trim() || `${label} #${index + 1}`,
type,
identityKey: buildProviderIdentityKey(type, index),
},
[item.authIndex],
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
);
});
});
// OpenAI 特殊处理:多 apiKeyEntries
(input.openaiCompatibility || []).forEach((provider, providerIndex) => {
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
const authIndices: Array<unknown> = [provider.authIndex];
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
authIndices.push(entry.authIndex);
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
registerCandidates(displayName, 'openai', Array.from(candidates));
registerProvider(
{
displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`,
type: 'openai',
identityKey: buildProviderIdentityKey('openai', providerIndex),
},
authIndices,
candidates
);
});
return map;
return { byAuthIndex, bySource };
}
export function resolveSourceDisplay(
sourceRaw: string,
authIndex: unknown,
sourceInfoMap: Map<string, SourceInfo>,
sourceInfoMap: SourceInfoMap,
authFileMap: Map<string, CredentialInfo>
): SourceInfo {
const source = sourceRaw.trim();
const matched = sourceInfoMap.get(source);
if (matched) return matched;
const authIndexKey = normalizeAuthIndex(authIndex);
if (authIndexKey) {
const matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey);
if (matchedByAuthIndex) {
return matchedByAuthIndex;
}
const authInfo = authFileMap.get(authIndexKey);
if (authInfo) {
return { displayName: authInfo.name || authIndexKey, type: authInfo.type };
return {
displayName: authInfo.name || authIndexKey,
type: authInfo.type,
identityKey: `auth:${authIndexKey}`,
};
}
}
const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null;
if (matchedBySource) {
return matchedBySource;
}
if (source) {
return {
displayName: formatRawSourceDisplayName(source),
type: '',
identityKey: `source:${source}`,
};
}
if (authIndexKey) {
return {
displayName: authIndexKey,
type: '',
identityKey: `auth:${authIndexKey}`,
};
}
return {
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
displayName: '-',
type: '',
identityKey: 'source:-',
};
}
+61
View File
@@ -0,0 +1,61 @@
const RFC3339_HIGH_PRECISION_REGEX =
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i;
/**
* Some browsers mis-handle RFC3339 timestamps that include sub-millisecond
* precision. Normalize them to millisecond precision before parsing.
*/
export function normalizeTimestampForDateParse(value: string): string {
const trimmed = value.trim();
if (!trimmed) return '';
const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX);
if (!match) return trimmed;
const [, base, , fractionDigits = '', timezone = ''] = match;
if (fractionDigits.length <= 3) {
return trimmed;
}
return `${base}.${fractionDigits.slice(0, 3)}${timezone}`;
}
export function parseTimestampMs(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (value instanceof Date) {
return value.getTime();
}
if (typeof value !== 'string') {
return Number.NaN;
}
const trimmed = value.trim();
if (!trimmed) {
return Number.NaN;
}
const normalized = normalizeTimestampForDateParse(trimmed);
const normalizedParsed = Date.parse(normalized);
if (!Number.isNaN(normalizedParsed)) {
return normalizedParsed;
}
if (normalized !== trimmed) {
const originalParsed = Date.parse(trimmed);
if (!Number.isNaN(originalParsed)) {
return originalParsed;
}
}
return Number.NaN;
}
export function parseTimestamp(value: unknown): Date | null {
const timestampMs = parseTimestampMs(value);
if (!Number.isFinite(timestampMs)) {
return null;
}
return new Date(timestampMs);
}
+25 -16
View File
@@ -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
View File
@@ -1,6 +1,8 @@
import type { UsageDetail } from '@/utils/usage';
import { normalizeAuthIndex } from '@/utils/usage';
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
export type UsageDetailsByAuthIndex = Map<string, UsageDetail[]>;
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
@@ -22,15 +24,47 @@ export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDet
return map;
}
export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex {
const map: UsageDetailsByAuthIndex = new Map();
usageDetails.forEach((detail) => {
const authIndexKey = normalizeAuthIndex(detail.auth_index);
if (!authIndexKey) return;
const bucket = map.get(authIndexKey);
if (bucket) {
bucket.push(detail);
} else {
map.set(authIndexKey, [detail]);
}
});
return map;
}
export function collectUsageDetailsForCandidates(
usageDetailsBySource: UsageDetailsBySource,
candidates: Iterable<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsBySource, candidates);
}
export function collectUsageDetailsForAuthIndices(
usageDetailsByAuthIndex: UsageDetailsByAuthIndex,
authIndices: Iterable<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices);
}
function collectUsageDetailsForKeys(
usageDetailsByKey: Map<string, UsageDetail[]>,
keys: Iterable<string>
): UsageDetail[] {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
for (const candidate of candidates) {
const details = usageDetailsBySource.get(candidate);
for (const key of keys) {
const details = usageDetailsByKey.get(key);
if (!details || details.length === 0) continue;
if (!firstDetails) {