Compare commits

..

12 Commits

23 changed files with 1949 additions and 936 deletions

15
package-lock.json generated
View File

@@ -71,6 +71,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -465,6 +466,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1930,6 +1932,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2017,6 +2020,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -2334,6 +2338,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2545,6 +2550,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2809,6 +2815,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3285,6 +3292,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -3614,6 +3622,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3720,6 +3729,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3737,6 +3747,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3845,6 +3856,7 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4027,6 +4039,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4103,6 +4116,7 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4244,6 +4258,7 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
@@ -61,6 +62,7 @@ function App() {
return (
<HashRouter>
<NotificationContainer />
<ConfirmationModal />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useNotificationStore } from '@/stores';
export function ConfirmationModal() {
const { t } = useTranslation();
const confirmation = useNotificationStore((state) => state.confirmation);
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
const { isOpen, isLoading, options } = confirmation;
if (!isOpen || !options) {
return null;
}
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
const handleConfirm = async () => {
try {
setConfirmationLoading(true);
await onConfirm();
hideConfirmation();
} catch (error) {
console.error('Confirmation action failed:', error);
// Optional: show error notification here if needed,
// but usually the calling component handles specific errors.
} finally {
setConfirmationLoading(false);
}
};
const handleCancel = () => {
if (isLoading) {
return;
}
if (onCancel) {
onCancel();
}
hideConfirmation();
};
return (
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
<p style={{ margin: '1rem 0' }}>{message}</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
loading={isLoading}
>
{confirmText || t('common.confirm')}
</Button>
</div>
</Modal>
);
}

View File

@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -55,11 +60,19 @@ export function ClaudeSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
configs.forEach((config) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
@@ -99,12 +112,11 @@ export function ClaudeSection({
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>

View File

@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -58,11 +63,19 @@ export function CodexSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
configs.forEach((config) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
@@ -106,12 +119,11 @@ export function CodexSection({
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>

View File

@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import type { GeminiFormState } from '../types';
import { ProviderList } from '../ProviderList';
@@ -55,11 +60,19 @@ export function GeminiSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
configs.forEach((config) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
@@ -99,12 +112,11 @@ export function GeminiSection({
/>
)}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>

View File

@@ -7,7 +7,12 @@ 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 { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -57,8 +62,15 @@ export function OpenAISection({
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
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
? usageDetails.filter((detail) => sourceIds.has(detail.source))
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
@@ -96,7 +108,7 @@ export function OpenAISection({
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
@@ -130,7 +142,7 @@ export function OpenAISection({
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
const entryStats = getStatsBySource(entry.apiKey, keyStats);
return (
<div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>

View File

@@ -5,7 +5,12 @@ import { Card } from '@/components/ui/Card';
import iconVertex from '@/assets/icons/vertex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -51,11 +56,19 @@ export function VertexSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
configs.forEach((config) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
@@ -86,10 +99,9 @@ export function VertexSection({
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>

View File

@@ -1,5 +1,5 @@
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
import type { AmpcodeFormState, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*';
@@ -62,33 +62,50 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
export const getStatsBySource = (
apiKey: string,
keyStats: KeyStats,
maskFn: (key: string) => string
prefix?: string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
const masked = maskFn(apiKey);
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
if (!candidates.length) {
return { success: 0, failure: 0 };
}
let success = 0;
let failure = 0;
candidates.forEach((candidate) => {
const stats = bySource[candidate];
if (!stats) return;
success += stats.success;
failure += stats.failure;
});
return { success, failure };
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats,
maskFn: (key: string) => string
providerPrefix?: string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
let totalSuccess = 0;
let totalFailure = 0;
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
(apiKeyEntries || []).forEach((entry) => {
const key = entry?.apiKey || '';
if (!key) return;
const masked = maskFn(key);
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
totalSuccess += stats.success;
totalFailure += stats.failure;
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
});
return { success: totalSuccess, failure: totalFailure };
let success = 0;
let failure = 0;
sourceIds.forEach((id) => {
const stats = bySource[id];
if (!stats) return;
success += stats.success;
failure += stats.failure;
});
return { success, failure };
};
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({

View File

@@ -18,7 +18,7 @@ import type {
GeminiCliQuotaBucketState,
GeminiCliQuotaState
} from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
@@ -55,6 +55,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
@@ -82,6 +84,43 @@ export interface QuotaConfig<TState, TData> {
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
try {
const text = await authFilesApi.downloadText(file.name);
const trimmed = text.trim();
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
if (topLevel) return topLevel;
const installed =
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
? (parsed.installed as Record<string, unknown>)
: null;
const installedProjectId = installed
? normalizeStringValue(installed.project_id ?? installed.projectId)
: null;
if (installedProjectId) return installedProjectId;
const web =
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
? (parsed.web as Record<string, unknown>)
: null;
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
if (webProjectId) return webProjectId;
} catch {
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
}
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
};
const isAntigravityUnknownFieldError = (message: string): boolean => {
const normalized = message.toLowerCase();
return normalized.includes('unknown name') && normalized.includes('cannot find field');
};
const fetchAntigravityQuota = async (
file: AuthFileItem,
t: TFunction
@@ -92,52 +131,64 @@ const fetchAntigravityQuota = async (
throw new Error(t('antigravity_quota.missing_auth_index'));
}
const projectId = await resolveAntigravityProjectId(file);
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
let lastError = '';
let lastStatus: number | undefined;
let priorityStatus: number | undefined;
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}'
});
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: requestBodies[attempt]
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
if (
result.statusCode === 400 &&
isAntigravityUnknownFieldError(lastError) &&
attempt < requestBodies.length - 1
) {
continue;
}
break;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
}

View File

@@ -8,6 +8,7 @@ interface ModalProps {
onClose: () => void;
footer?: ReactNode;
width?: number | string;
closeDisabled?: boolean;
}
const CLOSE_ANIMATION_DURATION = 350;
@@ -32,7 +33,15 @@ const unlockScroll = () => {
}
};
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
export function Modal({
open,
title,
onClose,
footer,
width = 520,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -106,7 +115,13 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
const modalContent = (
<div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
<button
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label="Close"
disabled={closeDisabled}
>
<IconX size={20} />
</button>
<div className="modal-header">

View File

@@ -4,6 +4,7 @@ interface ToggleSwitchProps {
checked: boolean;
onChange: (value: boolean) => void;
label?: ReactNode;
ariaLabel?: string;
disabled?: boolean;
labelPosition?: 'left' | 'right';
}
@@ -12,6 +13,7 @@ export function ToggleSwitch({
checked,
onChange,
label,
ariaLabel,
disabled = false,
labelPosition = 'right'
}: ToggleSwitchProps) {
@@ -25,7 +27,13 @@ export function ToggleSwitch({
return (
<label className={className}>
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
<input
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
aria-label={ariaLabel}
/>
<span className="track">
<span className="thumb" />
</span>

View File

@@ -395,7 +395,19 @@
"models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth"
"models_excluded_hint": "This model is excluded by OAuth",
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
"prefix_proxy_button": "Edit prefix/proxy_url",
"prefix_proxy_loading": "Loading credential...",
"prefix_proxy_source_label": "Credential JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
},
"antigravity_quota": {
"title": "Antigravity Quota",

View File

@@ -395,7 +395,19 @@
"models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除"
"models_excluded_hint": "此模型已被 OAuth 排除",
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
"prefix_proxy_button": "配置 prefix/proxy_url",
"prefix_proxy_loading": "正在加载凭证文件...",
"prefix_proxy_source_label": "凭证 JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
},
"antigravity_quota": {
"title": "Antigravity 额度",

View File

@@ -14,7 +14,7 @@ import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
@@ -29,7 +29,6 @@ export function ApiKeysPage() {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [inputValue, setInputValue] = useState('');
const [saving, setSaving] = useState(false);
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
@@ -115,21 +114,42 @@ export function ApiKeysPage() {
}
};
const handleDelete = async (index: number) => {
if (!window.confirm(t('api_keys.delete_confirm'))) return;
setDeletingIndex(index);
try {
await apiKeysApi.delete(index);
const nextKeys = apiKeys.filter((_, idx) => idx !== index);
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} finally {
setDeletingIndex(null);
const handleDelete = (index: number) => {
const apiKeyToDelete = apiKeys[index];
if (!apiKeyToDelete) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
showConfirmation({
title: t('common.delete'),
message: t('api_keys.delete_confirm'),
variant: 'danger',
onConfirm: async () => {
const latestKeys = useConfigStore.getState().config?.apiKeys;
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
const deleteIndex =
currentKeys[index] === apiKeyToDelete
? index
: currentKeys.findIndex((key) => key === apiKeyToDelete);
if (deleteIndex < 0) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
try {
await apiKeysApi.delete(deleteIndex);
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
}
});
};
const actionButtons = (
@@ -181,8 +201,7 @@ export function ApiKeysPage() {
variant="danger"
size="sm"
onClick={() => handleDelete(index)}
disabled={disableControls || deletingIndex === index}
loading={deletingIndex === index}
disabled={disableControls}
>
{t('common.delete')}
</Button>

View File

@@ -277,27 +277,15 @@
}
.antigravityCard {
background-image: linear-gradient(
180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0)
);
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
}
.codexCard {
background-image: linear-gradient(
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
}
.geminiCliCard {
background-image: linear-gradient(
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0)
);
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
}
.quotaSection {
@@ -446,7 +434,10 @@
display: flex;
flex-direction: column;
gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
transition:
transform $transition-fast,
box-shadow $transition-fast,
border-color $transition-fast;
&:hover {
transform: translateY(-2px);
@@ -546,7 +537,9 @@
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
transition:
transform 0.15s ease,
opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
@@ -597,14 +590,90 @@
background: var(--failure-badge-bg, #fee2e2);
}
.prefixProxyEditor {
display: flex;
flex-direction: column;
gap: $spacing-md;
max-height: 60vh;
overflow: auto;
}
.prefixProxyLoading {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
font-size: 12px;
color: var(--text-secondary);
padding: $spacing-sm 0;
}
.prefixProxyError {
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
border: 1px solid var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
font-size: 12px;
}
.prefixProxyJsonWrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.prefixProxyLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.prefixProxyTextarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
font-family: monospace;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
}
}
.prefixProxyFields {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-sm;
:global(.form-group) {
margin: 0;
}
}
.cardActions {
display: flex;
gap: $spacing-xs;
justify-content: flex-end;
align-items: center;
margin-top: auto;
padding-top: $spacing-sm;
}
.statusToggle {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: $spacing-sm;
}
.iconButton:global(.btn.btn-sm) {
width: 34px;
height: 34px;

File diff suppressed because it is too large Load Diff

View File

@@ -50,11 +50,6 @@ export function LoginPage() {
init();
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
const handleSubmit = async () => {
if (!managementKey.trim()) {
setError(t('login.error_required'));
@@ -81,12 +76,20 @@ export function LoginPage() {
}
};
const handleSubmitKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !loading) {
event.preventDefault();
handleSubmit();
}
}, [loading, handleSubmit]);
const handleSubmitKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !loading) {
event.preventDefault();
handleSubmit();
}
},
[loading, handleSubmit]
);
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
return (
<div className="login-page">

View File

@@ -6,10 +6,20 @@ import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelMappingEntry } from '@/types';
type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
const getStatusCode = (err: unknown): number | undefined => {
if (!err || typeof err !== 'object') return undefined;
if ('status' in err) return (err as StatusError).status;
return undefined;
};
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
if (!payload || typeof payload !== 'object') return {};
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload;
const record = payload as Record<string, unknown>;
const source = record['oauth-excluded-models'] ?? record.items ?? payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, string[]> = {};
@@ -43,9 +53,63 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
return result;
};
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
if (!payload || typeof payload !== 'object') return {};
const record = payload as Record<string, unknown>;
const source =
record['oauth-model-mappings'] ??
record['oauth-model-alias'] ??
record.items ??
payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, OAuthModelMappingEntry[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
const key = String(channel ?? '')
.trim()
.toLowerCase();
if (!key) return;
if (!Array.isArray(mappings)) return;
const seen = new Set<string>();
const normalized = mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const fork = entry.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
.filter(Boolean)
.filter((entry) => {
const mapping = entry as OAuthModelMappingEntry;
const dedupeKey = `${mapping.name.toLowerCase()}::${mapping.alias.toLowerCase()}::${mapping.fork ? '1' : '0'}`;
if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey);
return true;
}) as OAuthModelMappingEntry[];
if (normalized.length) {
result[key] = normalized;
}
});
return result;
};
const OAUTH_MODEL_MAPPINGS_ENDPOINT = '/oauth-model-mappings';
const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
upload: (file: File) => {
const formData = new FormData();
formData.append('file', file, file.name);
@@ -56,6 +120,14 @@ export const authFilesApi = {
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
downloadText: async (name: string): Promise<string> => {
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const blob = response.data as Blob;
return blob.text();
},
// OAuth 排除模型
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
const data = await apiClient.get('/oauth-excluded-models');
@@ -73,34 +145,63 @@ export const authFilesApi = {
// OAuth 模型映射
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
const data = await apiClient.get('/oauth-model-mappings');
const payload = (data && (data['oauth-model-mappings'] ?? data.items ?? data)) as any;
if (!payload || typeof payload !== 'object') return {};
const result: Record<string, OAuthModelMappingEntry[]> = {};
Object.entries(payload).forEach(([channel, mappings]) => {
if (!Array.isArray(mappings)) return;
const normalized = mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const name = String(item.name ?? item.id ?? item.model ?? '').trim();
const alias = String(item.alias ?? '').trim();
if (!name || !alias) return null;
const fork = item.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
.filter(Boolean) as OAuthModelMappingEntry[];
if (normalized.length) {
result[channel] = normalized;
}
});
return result;
try {
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT);
return normalizeOauthModelMappings(data);
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
return normalizeOauthModelMappings(data);
}
},
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
apiClient.patch('/oauth-model-mappings', { channel, mappings }),
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? [];
deleteOauthModelMappings: (channel: string) =>
apiClient.delete(`/oauth-model-mappings?channel=${encodeURIComponent(channel)}`),
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: normalizedMappings });
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: normalizedMappings });
}
},
deleteOauthModelMappings: async (channel: string) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const deleteViaPatch = async () => {
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: [] });
return true;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: [] });
return true;
}
};
try {
await deleteViaPatch();
return;
} catch (err: unknown) {
const status = getStatusCode(err);
if (status !== 405) throw err;
}
try {
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
}
},
// 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {

View File

@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
import { generateId } from '@/utils/helpers';
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
interface ConfirmationOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'primary' | 'secondary';
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
}
interface NotificationState {
notifications: Notification[];
confirmation: {
isOpen: boolean;
isLoading: boolean;
options: ConfirmationOptions | null;
};
showNotification: (message: string, type?: NotificationType, duration?: number) => void;
removeNotification: (id: string) => void;
clearAll: () => void;
showConfirmation: (options: ConfirmationOptions) => void;
hideConfirmation: () => void;
setConfirmationLoading: (loading: boolean) => void;
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
confirmation: {
isOpen: false,
isLoading: false,
options: null
},
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
const id = generateId();
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
clearAll: () => {
set({ notifications: [] });
},
showConfirmation: (options) => {
set({
confirmation: {
isOpen: true,
isLoading: false,
options
}
});
},
hideConfirmation: () => {
set((state) => ({
confirmation: {
...state.confirmation,
isOpen: false,
options: null // Cleanup
}
}));
},
setConfirmationLoading: (loading) => {
set((state) => ({
confirmation: {
...state.confirmation,
isLoading: loading
}
}));
}
}));

View File

@@ -453,6 +453,18 @@ textarea {
&:active {
transform: scale(0.95);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
&:disabled:hover {
color: var(--text-secondary);
background: var(--bg-secondary);
transform: none;
}
}
.modal-header {

View File

@@ -7,14 +7,16 @@
* 隐藏 API Key 中间部分,仅保留前后两位
*/
export function maskApiKey(key: string): string {
if (!key) {
const trimmed = String(key || '').trim();
if (!trimmed) {
return '';
}
const visibleChars = 2;
const start = key.slice(0, visibleChars);
const end = key.slice(-visibleChars);
const maskedLength = Math.max(key.length - visibleChars * 2, 1);
const MASKED_LENGTH = 10;
const visibleChars = trimmed.length < 4 ? 1 : 2;
const start = trimmed.slice(0, visibleChars);
const end = trimmed.slice(-visibleChars);
const maskedLength = Math.max(MASKED_LENGTH - visibleChars * 2, 1);
const masked = '*'.repeat(maskedLength);
return `${start}${masked}${end}`;

View File

@@ -73,6 +73,124 @@ const normalizeAuthIndex = (value: any) => {
return null;
};
const USAGE_SOURCE_PREFIX_KEY = 'k:';
const USAGE_SOURCE_PREFIX_MASKED = 'm:';
const USAGE_SOURCE_PREFIX_TEXT = 't:';
const KEY_LIKE_TOKEN_REGEX =
/(sk-[A-Za-z0-9-_]{6,}|sk-ant-[A-Za-z0-9-_]{6,}|AIza[0-9A-Za-z-_]{8,}|AI[a-zA-Z0-9_-]{6,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/;
const MASKED_TOKEN_HINT_REGEX = /^[^\s]{1,24}(\*{2,}|\.{3}|…)[^\s]{1,24}$/;
const keyFingerprintCache = new Map<string, string>();
const fnv1a64Hex = (value: string): string => {
const cached = keyFingerprintCache.get(value);
if (cached) return cached;
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
let hash = FNV_OFFSET_BASIS;
for (let i = 0; i < value.length; i++) {
hash ^= BigInt(value.charCodeAt(i));
hash = (hash * FNV_PRIME) & 0xffffffffffffffffn;
}
const hex = hash.toString(16).padStart(16, '0');
keyFingerprintCache.set(value, hex);
return hex;
};
const looksLikeRawSecret = (text: string): boolean => {
if (!text || /\s/.test(text)) return false;
const lower = text.toLowerCase();
if (lower.endsWith('.json')) return false;
if (lower.startsWith('http://') || lower.startsWith('https://')) return false;
if (/[\\/]/.test(text)) return false;
if (KEY_LIKE_TOKEN_REGEX.test(text)) return true;
if (text.length >= 32 && text.length <= 512) {
return true;
}
if (text.length >= 16 && text.length < 32 && /^[A-Za-z0-9._=-]+$/.test(text)) {
return /[A-Za-z]/.test(text) && /\d/.test(text);
}
return false;
};
const extractRawSecretFromText = (text: string): string | null => {
if (!text) return null;
if (looksLikeRawSecret(text)) return text;
const keyLikeMatch = text.match(KEY_LIKE_TOKEN_REGEX);
if (keyLikeMatch?.[0]) return keyLikeMatch[0];
const queryMatch = text.match(
/(?:[?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/i
);
const queryValue = queryMatch?.[2];
if (queryValue && looksLikeRawSecret(queryValue)) {
return queryValue;
}
const headerMatch = text.match(
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*[:=]\s*([A-Za-z0-9._=-]+)/i
);
const headerValue = headerMatch?.[2];
if (headerValue && looksLikeRawSecret(headerValue)) {
return headerValue;
}
const bearerMatch = text.match(/\bBearer\s+([A-Za-z0-9._=-]{6,})/i);
const bearerValue = bearerMatch?.[1];
if (bearerValue && looksLikeRawSecret(bearerValue)) {
return bearerValue;
}
return null;
};
export function normalizeUsageSourceId(
value: unknown,
masker: (val: string) => string = maskApiKey
): string {
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
const trimmed = raw.trim();
if (!trimmed) return '';
const extracted = extractRawSecretFromText(trimmed);
if (extracted) {
return `${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(extracted)}`;
}
if (MASKED_TOKEN_HINT_REGEX.test(trimmed)) {
return `${USAGE_SOURCE_PREFIX_MASKED}${masker(trimmed)}`;
}
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
}
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
const result: string[] = [];
const prefix = input.prefix?.trim();
if (prefix) {
result.push(`${USAGE_SOURCE_PREFIX_TEXT}${prefix}`);
}
const apiKey = input.apiKey?.trim();
if (apiKey) {
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
}
return Array.from(new Set(result));
}
/**
* 对使用数据中的敏感字段进行遮罩
*/
@@ -200,6 +318,7 @@ export function collectUsageDetails(usageData: any): UsageDetail[] {
if (detail && detail.timestamp) {
details.push({
...detail,
source: normalizeUsageSourceId(detail.source),
__modelName: modelName
});
}
@@ -878,7 +997,7 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string
const details = modelEntry?.details || [];
details.forEach((detail: any) => {
const source = maskUsageSensitiveValue(detail?.source, masker);
const source = normalizeUsageSourceId(detail?.source, masker);
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
const isFailed = detail?.failed === true;