diff --git a/package-lock.json b/package-lock.json index fcad3ab..775d6e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index c937159..275aedd 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -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>(); - const allApiKeys = new Set(); - 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 ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index bd53bd1..9facbe3 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -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>(); - const allApiKeys = new Set(); - 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 ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index dcdb69c..a53aba1 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -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>(); - const allApiKeys = new Set(); - 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 ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index a444740..a17e69b 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -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>(); 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(); + 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({
{apiKeyEntries.map((entry, entryIndex) => { - const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey); + const entryStats = getStatsBySource(entry.apiKey, keyStats); return (
{entryIndex + 1} diff --git a/src/components/providers/VertexSection/VertexSection.tsx b/src/components/providers/VertexSection/VertexSection.tsx index 8282aac..b3faa6b 100644 --- a/src/components/providers/VertexSection/VertexSection.tsx +++ b/src/components/providers/VertexSection/VertexSection.tsx @@ -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>(); - const allApiKeys = new Set(); - 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 ( diff --git a/src/components/providers/utils.ts b/src/components/providers/utils.ts index 746b721..e44bd91 100644 --- a/src/components/providers/utils.ts +++ b/src/components/providers/utils.ts @@ -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(); + 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 => ({ diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index f4ced4c..3674a61 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -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; } diff --git a/src/utils/format.ts b/src/utils/format.ts index 0f0dfb0..55e0f3b 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -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}`; diff --git a/src/utils/usage.ts b/src/utils/usage.ts index ae2ccfc..48ac4df 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -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(); + +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;