fix(ui): use fixed-length key masking and fingerprint usage sources

This commit is contained in:
LTbinglingfeng
2026-01-19 00:41:11 +08:00
parent d79ccc480d
commit d077b5dd26
10 changed files with 283 additions and 62 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

@@ -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

@@ -14,8 +14,14 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
import {
calculateStatusBarData,
collectUsageDetails,
normalizeUsageSourceId,
type KeyStatBucket,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import { generateId } from '@/utils/helpers';
import styles from './AuthFilesPage.module.scss';
@@ -142,8 +148,9 @@ function resolveAuthFileStats(
}
// 尝试根据 source (文件名) 匹配
if (rawFileName && stats.bySource?.[rawFileName]) {
const fromName = stats.bySource[rawFileName];
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
if (fileNameId && stats.bySource?.[fileNameId]) {
const fromName = stats.bySource[fileNameId];
if (fromName.success > 0 || fromName.failure > 0) {
return fromName;
}
@@ -153,7 +160,8 @@ function resolveAuthFileStats(
if (rawFileName) {
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
const fromNameWithoutExt = stats.bySource?.[nameWithoutExt];
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
if (fromNameWithoutExt && (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)) {
return fromNameWithoutExt;
}

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;