mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 00:46:42 +00:00
feat: implement token key fetching and masking in API responses
This commit is contained in:
@@ -29,7 +29,6 @@ const TokensActions = ({
|
||||
setShowEdit,
|
||||
batchCopyTokens,
|
||||
batchDeleteTokens,
|
||||
copyText,
|
||||
t,
|
||||
}) => {
|
||||
// Modal states
|
||||
@@ -99,8 +98,7 @@ const TokensActions = ({
|
||||
<CopyTokensModal
|
||||
visible={showCopyModal}
|
||||
onCancel={() => setShowCopyModal(false)}
|
||||
selectedKeys={selectedKeys}
|
||||
copyText={copyText}
|
||||
batchCopyTokens={batchCopyTokens}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
|
||||
@@ -108,17 +108,28 @@ const renderGroupColumn = (text, record, t) => {
|
||||
};
|
||||
|
||||
// Render token key column with show/hide and copy functionality
|
||||
const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
|
||||
const fullKey = 'sk-' + record.key;
|
||||
const maskedKey =
|
||||
'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
|
||||
const renderTokenKey = (
|
||||
text,
|
||||
record,
|
||||
showKeys,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
) => {
|
||||
const revealed = !!showKeys[record.id];
|
||||
const loading = !!loadingTokenKeys[record.id];
|
||||
const keyValue =
|
||||
revealed && resolvedTokenKeys[record.id]
|
||||
? resolvedTokenKeys[record.id]
|
||||
: record.key || '';
|
||||
const displayedKey = keyValue ? `sk-${keyValue}` : '';
|
||||
|
||||
return (
|
||||
<div className='w-[200px]'>
|
||||
<Input
|
||||
readOnly
|
||||
value={revealed ? fullKey : maskedKey}
|
||||
value={displayedKey}
|
||||
size='small'
|
||||
suffix={
|
||||
<div className='flex items-center'>
|
||||
@@ -127,10 +138,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
|
||||
loading={loading}
|
||||
aria-label='toggle token visibility'
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setShowKeys((prev) => ({ ...prev, [record.id]: !revealed }));
|
||||
await toggleTokenVisibility(record);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -138,10 +150,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={<IconCopy />}
|
||||
loading={loading}
|
||||
aria-label='copy token key'
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await copyText(fullKey);
|
||||
await copyTokenKey(record);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -427,8 +440,10 @@ const renderOperations = (
|
||||
export const getTokensColumns = ({
|
||||
t,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
@@ -461,7 +476,15 @@ export const getTokensColumns = ({
|
||||
title: t('密钥'),
|
||||
key: 'token_key',
|
||||
render: (text, record) =>
|
||||
renderTokenKey(text, record, showKeys, setShowKeys, copyText),
|
||||
renderTokenKey(
|
||||
text,
|
||||
record,
|
||||
showKeys,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('可用模型'),
|
||||
|
||||
@@ -39,8 +39,10 @@ const TokensTable = (tokensData) => {
|
||||
rowSelection,
|
||||
handleRow,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
@@ -54,8 +56,10 @@ const TokensTable = (tokensData) => {
|
||||
return getTokensColumns({
|
||||
t,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
@@ -65,8 +69,10 @@ const TokensTable = (tokensData) => {
|
||||
}, [
|
||||
t,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
|
||||
@@ -58,6 +58,7 @@ function TokensPage() {
|
||||
t: (k) => k,
|
||||
selectedModel: '',
|
||||
prefillKey: '',
|
||||
fetchTokenKey: async () => '',
|
||||
});
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
@@ -74,6 +75,7 @@ function TokensPage() {
|
||||
t: tokensData.t,
|
||||
selectedModel,
|
||||
prefillKey,
|
||||
fetchTokenKey: tokensData.fetchTokenKey,
|
||||
};
|
||||
}, [
|
||||
tokensData.tokens,
|
||||
@@ -81,6 +83,7 @@ function TokensPage() {
|
||||
tokensData.t,
|
||||
selectedModel,
|
||||
prefillKey,
|
||||
tokensData.fetchTokenKey,
|
||||
]);
|
||||
|
||||
const loadModels = async () => {
|
||||
@@ -198,13 +201,14 @@ function TokensPage() {
|
||||
openCCSwitchModalRef.current = openCCSwitchModal;
|
||||
|
||||
// Prefill to Fluent handler
|
||||
const handlePrefillToFluent = () => {
|
||||
const handlePrefillToFluent = async () => {
|
||||
const {
|
||||
tokens,
|
||||
selectedKeys,
|
||||
t,
|
||||
selectedModel: chosenModel,
|
||||
prefillKey: overrideKey,
|
||||
fetchTokenKey,
|
||||
} = latestRef.current;
|
||||
const container = document.getElementById('fluent-new-api-container');
|
||||
if (!container) {
|
||||
@@ -241,7 +245,11 @@ function TokensPage() {
|
||||
Toast.warning(t('没有可用令牌用于填充'));
|
||||
return;
|
||||
}
|
||||
apiKeyToUse = 'sk-' + token.key;
|
||||
try {
|
||||
apiKeyToUse = 'sk-' + (await fetchTokenKey(token));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -351,7 +359,6 @@ function TokensPage() {
|
||||
setShowEdit,
|
||||
batchCopyTokens,
|
||||
batchDeleteTokens,
|
||||
copyText,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
@@ -401,7 +408,6 @@ function TokensPage() {
|
||||
setShowEdit={setShowEdit}
|
||||
batchCopyTokens={batchCopyTokens}
|
||||
batchDeleteTokens={batchDeleteTokens}
|
||||
copyText={copyText}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
|
||||
@@ -116,8 +116,7 @@ export default function CCSwitchModal({
|
||||
Toast.warning(t('请选择主模型'));
|
||||
return;
|
||||
}
|
||||
const apiKey = 'sk-' + tokenKey;
|
||||
const url = buildCCSwitchURL(app, name, models, apiKey);
|
||||
const url = buildCCSwitchURL(app, name, models, 'sk-' + tokenKey);
|
||||
window.open(url, '_blank');
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -20,24 +20,21 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Modal, Button, Space } from '@douyinfe/semi-ui';
|
||||
|
||||
const CopyTokensModal = ({ visible, onCancel, selectedKeys, copyText, t }) => {
|
||||
const CopyTokensModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
batchCopyTokens,
|
||||
t,
|
||||
}) => {
|
||||
// Handle copy with name and key format
|
||||
const handleCopyWithName = async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
await batchCopyTokens('name+key');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// Handle copy with key only format
|
||||
const handleCopyKeyOnly = async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += 'sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
await batchCopyTokens('key-only');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const ColumnSelectorModal = ({
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={billingDisplayMode}
|
||||
onChange={(event) => setBillingDisplayMode(event.target.value)}
|
||||
onChange={(value) => setBillingDisplayMode(value)}
|
||||
>
|
||||
<Radio value='price'>
|
||||
{isTokensDisplay ? t('价格模式') : t('价格模式(默认)')}
|
||||
|
||||
25
web/src/helpers/token.js
vendored
25
web/src/helpers/token.js
vendored
@@ -20,8 +20,22 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { API } from './api';
|
||||
|
||||
/**
|
||||
* 获取可用的token keys
|
||||
* @returns {Promise<string[]>} 返回active状态的token key数组
|
||||
* 按需获取单个令牌的真实 key
|
||||
* @param {number|string} tokenId
|
||||
* @returns {Promise<string>} 返回不带 sk- 前缀的真实 token key
|
||||
*/
|
||||
export async function fetchTokenKey(tokenId) {
|
||||
const response = await API.post(`/api/token/${tokenId}/key`);
|
||||
const { success, data, message } = response.data || {};
|
||||
if (!success || !data?.key) {
|
||||
throw new Error(message || 'Failed to fetch token key');
|
||||
}
|
||||
return data.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的 token keys
|
||||
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
|
||||
*/
|
||||
export async function fetchTokenKeys() {
|
||||
try {
|
||||
@@ -31,7 +45,12 @@ export async function fetchTokenKeys() {
|
||||
|
||||
const tokenItems = Array.isArray(data) ? data : data.items || [];
|
||||
const activeTokens = tokenItems.filter((token) => token.status === 1);
|
||||
return activeTokens.map((token) => token.key);
|
||||
const keyResults = await Promise.allSettled(
|
||||
activeTokens.map((token) => fetchTokenKey(token.id)),
|
||||
);
|
||||
return keyResults
|
||||
.filter((result) => result.status === 'fulfilled' && result.value)
|
||||
.map((result) => result.value);
|
||||
} catch (error) {
|
||||
console.error('Error fetching token keys:', error);
|
||||
return [];
|
||||
|
||||
150
web/src/hooks/tokens/useTokensData.jsx
vendored
150
web/src/hooks/tokens/useTokensData.jsx
vendored
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
|
||||
|
||||
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -54,6 +55,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
// UI state
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
|
||||
const [showKeys, setShowKeys] = useState({});
|
||||
const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
|
||||
const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
|
||||
const keyRequestsRef = useRef({});
|
||||
|
||||
// Form state
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
@@ -87,6 +91,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
setTokenCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
setShowKeys({});
|
||||
};
|
||||
|
||||
// Load tokens function
|
||||
@@ -122,14 +127,86 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTokenKey = async (tokenOrId, options = {}) => {
|
||||
const { suppressError = false } = options;
|
||||
const tokenId =
|
||||
typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
|
||||
|
||||
if (!tokenId) {
|
||||
const error = new Error(t('令牌不存在'));
|
||||
if (!suppressError) {
|
||||
showError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resolvedTokenKeys[tokenId]) {
|
||||
return resolvedTokenKeys[tokenId];
|
||||
}
|
||||
|
||||
if (keyRequestsRef.current[tokenId]) {
|
||||
return keyRequestsRef.current[tokenId];
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
|
||||
try {
|
||||
const fullKey = await fetchTokenKeyById(tokenId);
|
||||
setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
|
||||
return fullKey;
|
||||
} catch (error) {
|
||||
const normalizedError = new Error(
|
||||
error?.message || t('获取令牌密钥失败'),
|
||||
);
|
||||
if (!suppressError) {
|
||||
showError(normalizedError.message);
|
||||
}
|
||||
throw normalizedError;
|
||||
} finally {
|
||||
delete keyRequestsRef.current[tokenId];
|
||||
setLoadingTokenKeys((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[tokenId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
keyRequestsRef.current[tokenId] = request;
|
||||
return request;
|
||||
};
|
||||
|
||||
const toggleTokenVisibility = async (record) => {
|
||||
const tokenId = record?.id;
|
||||
if (!tokenId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showKeys[tokenId]) {
|
||||
setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
if (fullKey) {
|
||||
setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const copyTokenKey = async (record) => {
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
await copyText(`sk-${fullKey}`);
|
||||
};
|
||||
|
||||
// Open link function for chat integrations
|
||||
const onOpenLink = async (type, url, record) => {
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
if (url && url.startsWith('ccswitch')) {
|
||||
openCCSwitchModal(record.key);
|
||||
openCCSwitchModal(fullKey);
|
||||
return;
|
||||
}
|
||||
if (url && url.startsWith('fluent')) {
|
||||
openFluentNotification(record.key);
|
||||
openFluentNotification(fullKey);
|
||||
return;
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
@@ -145,7 +222,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
let cherryConfig = {
|
||||
id: 'new-api',
|
||||
baseUrl: serverAddress,
|
||||
apiKey: 'sk-' + record.key,
|
||||
apiKey: `sk-${fullKey}`,
|
||||
};
|
||||
let encodedConfig = encodeURIComponent(
|
||||
encodeToBase64(JSON.stringify(cherryConfig)),
|
||||
@@ -155,7 +232,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
let aionuiConfig = {
|
||||
platform: 'new-api',
|
||||
baseUrl: serverAddress,
|
||||
apiKey: 'sk-' + record.key,
|
||||
apiKey: `sk-${fullKey}`,
|
||||
};
|
||||
let encodedConfig = encodeURIComponent(
|
||||
encodeToBase64(JSON.stringify(aionuiConfig)),
|
||||
@@ -164,7 +241,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
} else {
|
||||
let encodedServerAddress = encodeURIComponent(serverAddress);
|
||||
url = url.replaceAll('{address}', encodedServerAddress);
|
||||
url = url.replaceAll('{key}', 'sk-' + record.key);
|
||||
url = url.replaceAll('{key}', `sk-${fullKey}`);
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
@@ -314,48 +391,28 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
};
|
||||
|
||||
// Batch copy tokens
|
||||
const batchCopyTokens = (copyType) => {
|
||||
const batchCopyTokens = async (copyType) => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.info({
|
||||
title: t('复制令牌'),
|
||||
icon: null,
|
||||
content: t('请选择你的复制方式'),
|
||||
footer: (
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
className='px-3 py-1 bg-gray-200 rounded'
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('名称+密钥')}
|
||||
</button>
|
||||
<button
|
||||
className='px-3 py-1 bg-blue-500 text-white rounded'
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += 'sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('仅密钥')}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
try {
|
||||
const keys = await Promise.all(
|
||||
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
|
||||
);
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
const fullKey = keys[i];
|
||||
if (copyType === 'name+key') {
|
||||
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
|
||||
} else {
|
||||
content += `sk-${fullKey}\n`;
|
||||
}
|
||||
}
|
||||
await copyText(content);
|
||||
} catch (error) {
|
||||
showError(error?.message || t('复制令牌失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
@@ -392,6 +449,8 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
setCompactMode,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
@@ -403,6 +462,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
loadTokens,
|
||||
refresh,
|
||||
copyText,
|
||||
fetchTokenKey,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
onOpenLink,
|
||||
manageToken,
|
||||
searchTokens,
|
||||
|
||||
Reference in New Issue
Block a user