Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
d67f446b
authored
Mar 08, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement token key fetching and masking in API responses
parent
ac72f90f
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
525 additions
and
93 deletions
+525
-93
controller/token.go
+37
-12
controller/token_test.go
+275
-0
model/token.go
+22
-1
router/api-router.go
+1
-0
web/src/components/table/tokens/TokensActions.jsx
+1
-3
web/src/components/table/tokens/TokensColumnDefs.jsx
+34
-11
web/src/components/table/tokens/TokensTable.jsx
+12
-6
web/src/components/table/tokens/index.jsx
+10
-4
web/src/components/table/tokens/modals/CCSwitchModal.jsx
+1
-2
web/src/components/table/tokens/modals/CopyTokensModal.jsx
+8
-11
web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx
+1
-1
web/src/helpers/token.js
+22
-3
web/src/hooks/tokens/useTokensData.jsx
+101
-39
No files found.
controller/token.go
View file @
d67f446b
...
...
@@ -14,6 +14,23 @@ import (
"github.com/gin-gonic/gin"
)
func
buildMaskedTokenResponse
(
token
*
model
.
Token
)
*
model
.
Token
{
if
token
==
nil
{
return
nil
}
maskedToken
:=
*
token
maskedToken
.
Key
=
token
.
GetMaskedKey
()
return
&
maskedToken
}
func
buildMaskedTokenResponses
(
tokens
[]
*
model
.
Token
)
[]
*
model
.
Token
{
maskedTokens
:=
make
([]
*
model
.
Token
,
0
,
len
(
tokens
))
for
_
,
token
:=
range
tokens
{
maskedTokens
=
append
(
maskedTokens
,
buildMaskedTokenResponse
(
token
))
}
return
maskedTokens
}
func
GetAllTokens
(
c
*
gin
.
Context
)
{
userId
:=
c
.
GetInt
(
"id"
)
pageInfo
:=
common
.
GetPageQuery
(
c
)
...
...
@@ -24,9 +41,8 @@ func GetAllTokens(c *gin.Context) {
}
total
,
_
:=
model
.
CountUserTokens
(
userId
)
pageInfo
.
SetTotal
(
int
(
total
))
pageInfo
.
SetItems
(
tokens
)
pageInfo
.
SetItems
(
buildMaskedTokenResponses
(
tokens
)
)
common
.
ApiSuccess
(
c
,
pageInfo
)
return
}
func
SearchTokens
(
c
*
gin
.
Context
)
{
...
...
@@ -42,9 +58,8 @@ func SearchTokens(c *gin.Context) {
return
}
pageInfo
.
SetTotal
(
int
(
total
))
pageInfo
.
SetItems
(
tokens
)
pageInfo
.
SetItems
(
buildMaskedTokenResponses
(
tokens
)
)
common
.
ApiSuccess
(
c
,
pageInfo
)
return
}
func
GetToken
(
c
*
gin
.
Context
)
{
...
...
@@ -59,12 +74,24 @@ func GetToken(c *gin.Context) {
common
.
ApiError
(
c
,
err
)
return
}
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
""
,
"data"
:
token
,
})
common
.
ApiSuccess
(
c
,
buildMaskedTokenResponse
(
token
))
}
func
GetTokenKey
(
c
*
gin
.
Context
)
{
id
,
err
:=
strconv
.
Atoi
(
c
.
Param
(
"id"
))
userId
:=
c
.
GetInt
(
"id"
)
if
err
!=
nil
{
common
.
ApiError
(
c
,
err
)
return
}
token
,
err
:=
model
.
GetTokenByIds
(
id
,
userId
)
if
err
!=
nil
{
common
.
ApiError
(
c
,
err
)
return
}
common
.
ApiSuccess
(
c
,
gin
.
H
{
"key"
:
token
.
GetFullKey
(),
})
}
func
GetTokenStatus
(
c
*
gin
.
Context
)
{
...
...
@@ -204,7 +231,6 @@ func AddToken(c *gin.Context) {
"success"
:
true
,
"message"
:
""
,
})
return
}
func
DeleteToken
(
c
*
gin
.
Context
)
{
...
...
@@ -219,7 +245,6 @@ func DeleteToken(c *gin.Context) {
"success"
:
true
,
"message"
:
""
,
})
return
}
func
UpdateToken
(
c
*
gin
.
Context
)
{
...
...
@@ -283,7 +308,7 @@ func UpdateToken(c *gin.Context) {
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
""
,
"data"
:
cleanToken
,
"data"
:
buildMaskedTokenResponse
(
cleanToken
)
,
})
}
...
...
controller/token_test.go
0 → 100644
View file @
d67f446b
package
controller
import
(
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type
tokenAPIResponse
struct
{
Success
bool
`json:"success"`
Message
string
`json:"message"`
Data
json
.
RawMessage
`json:"data"`
}
type
tokenPageResponse
struct
{
Items
[]
tokenResponseItem
`json:"items"`
}
type
tokenResponseItem
struct
{
ID
int
`json:"id"`
Name
string
`json:"name"`
Key
string
`json:"key"`
Status
int
`json:"status"`
}
type
tokenKeyResponse
struct
{
Key
string
`json:"key"`
}
func
setupTokenControllerTestDB
(
t
*
testing
.
T
)
*
gorm
.
DB
{
t
.
Helper
()
gin
.
SetMode
(
gin
.
TestMode
)
common
.
UsingSQLite
=
true
common
.
UsingMySQL
=
false
common
.
UsingPostgreSQL
=
false
common
.
RedisEnabled
=
false
dsn
:=
fmt
.
Sprintf
(
"file:%s?mode=memory&cache=shared"
,
strings
.
ReplaceAll
(
t
.
Name
(),
"/"
,
"_"
))
db
,
err
:=
gorm
.
Open
(
sqlite
.
Open
(
dsn
),
&
gorm
.
Config
{})
if
err
!=
nil
{
t
.
Fatalf
(
"failed to open sqlite db: %v"
,
err
)
}
model
.
DB
=
db
model
.
LOG_DB
=
db
if
err
:=
db
.
AutoMigrate
(
&
model
.
Token
{});
err
!=
nil
{
t
.
Fatalf
(
"failed to migrate token table: %v"
,
err
)
}
t
.
Cleanup
(
func
()
{
sqlDB
,
err
:=
db
.
DB
()
if
err
==
nil
{
_
=
sqlDB
.
Close
()
}
})
return
db
}
func
seedToken
(
t
*
testing
.
T
,
db
*
gorm
.
DB
,
userID
int
,
name
string
,
rawKey
string
)
*
model
.
Token
{
t
.
Helper
()
token
:=
&
model
.
Token
{
UserId
:
userID
,
Name
:
name
,
Key
:
rawKey
,
Status
:
common
.
TokenStatusEnabled
,
CreatedTime
:
1
,
AccessedTime
:
1
,
ExpiredTime
:
-
1
,
RemainQuota
:
100
,
UnlimitedQuota
:
true
,
Group
:
"default"
,
}
if
err
:=
db
.
Create
(
token
)
.
Error
;
err
!=
nil
{
t
.
Fatalf
(
"failed to create token: %v"
,
err
)
}
return
token
}
func
newAuthenticatedContext
(
t
*
testing
.
T
,
method
string
,
target
string
,
body
any
,
userID
int
)
(
*
gin
.
Context
,
*
httptest
.
ResponseRecorder
)
{
t
.
Helper
()
var
requestBody
*
bytes
.
Reader
if
body
!=
nil
{
payload
,
err
:=
common
.
Marshal
(
body
)
if
err
!=
nil
{
t
.
Fatalf
(
"failed to marshal request body: %v"
,
err
)
}
requestBody
=
bytes
.
NewReader
(
payload
)
}
else
{
requestBody
=
bytes
.
NewReader
(
nil
)
}
recorder
:=
httptest
.
NewRecorder
()
ctx
,
_
:=
gin
.
CreateTestContext
(
recorder
)
ctx
.
Request
=
httptest
.
NewRequest
(
method
,
target
,
requestBody
)
if
body
!=
nil
{
ctx
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
}
ctx
.
Set
(
"id"
,
userID
)
return
ctx
,
recorder
}
func
decodeAPIResponse
(
t
*
testing
.
T
,
recorder
*
httptest
.
ResponseRecorder
)
tokenAPIResponse
{
t
.
Helper
()
var
response
tokenAPIResponse
if
err
:=
common
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
response
);
err
!=
nil
{
t
.
Fatalf
(
"failed to decode api response: %v"
,
err
)
}
return
response
}
func
TestGetAllTokensMasksKeyInResponse
(
t
*
testing
.
T
)
{
db
:=
setupTokenControllerTestDB
(
t
)
token
:=
seedToken
(
t
,
db
,
1
,
"list-token"
,
"abcd1234efgh5678"
)
seedToken
(
t
,
db
,
2
,
"other-user-token"
,
"zzzz1234yyyy5678"
)
ctx
,
recorder
:=
newAuthenticatedContext
(
t
,
http
.
MethodGet
,
"/api/token/?p=1&size=10"
,
nil
,
1
)
GetAllTokens
(
ctx
)
response
:=
decodeAPIResponse
(
t
,
recorder
)
if
!
response
.
Success
{
t
.
Fatalf
(
"expected success response, got message: %s"
,
response
.
Message
)
}
var
page
tokenPageResponse
if
err
:=
common
.
Unmarshal
(
response
.
Data
,
&
page
);
err
!=
nil
{
t
.
Fatalf
(
"failed to decode token page response: %v"
,
err
)
}
if
len
(
page
.
Items
)
!=
1
{
t
.
Fatalf
(
"expected exactly one token, got %d"
,
len
(
page
.
Items
))
}
if
page
.
Items
[
0
]
.
Key
!=
token
.
GetMaskedKey
()
{
t
.
Fatalf
(
"expected masked key %q, got %q"
,
token
.
GetMaskedKey
(),
page
.
Items
[
0
]
.
Key
)
}
if
strings
.
Contains
(
recorder
.
Body
.
String
(),
token
.
Key
)
{
t
.
Fatalf
(
"list response leaked raw token key: %s"
,
recorder
.
Body
.
String
())
}
}
func
TestSearchTokensMasksKeyInResponse
(
t
*
testing
.
T
)
{
db
:=
setupTokenControllerTestDB
(
t
)
token
:=
seedToken
(
t
,
db
,
1
,
"searchable-token"
,
"ijkl1234mnop5678"
)
ctx
,
recorder
:=
newAuthenticatedContext
(
t
,
http
.
MethodGet
,
"/api/token/search?keyword=searchable-token&p=1&size=10"
,
nil
,
1
)
SearchTokens
(
ctx
)
response
:=
decodeAPIResponse
(
t
,
recorder
)
if
!
response
.
Success
{
t
.
Fatalf
(
"expected success response, got message: %s"
,
response
.
Message
)
}
var
page
tokenPageResponse
if
err
:=
common
.
Unmarshal
(
response
.
Data
,
&
page
);
err
!=
nil
{
t
.
Fatalf
(
"failed to decode search response: %v"
,
err
)
}
if
len
(
page
.
Items
)
!=
1
{
t
.
Fatalf
(
"expected exactly one search result, got %d"
,
len
(
page
.
Items
))
}
if
page
.
Items
[
0
]
.
Key
!=
token
.
GetMaskedKey
()
{
t
.
Fatalf
(
"expected masked search key %q, got %q"
,
token
.
GetMaskedKey
(),
page
.
Items
[
0
]
.
Key
)
}
if
strings
.
Contains
(
recorder
.
Body
.
String
(),
token
.
Key
)
{
t
.
Fatalf
(
"search response leaked raw token key: %s"
,
recorder
.
Body
.
String
())
}
}
func
TestGetTokenMasksKeyInResponse
(
t
*
testing
.
T
)
{
db
:=
setupTokenControllerTestDB
(
t
)
token
:=
seedToken
(
t
,
db
,
1
,
"detail-token"
,
"qrst1234uvwx5678"
)
ctx
,
recorder
:=
newAuthenticatedContext
(
t
,
http
.
MethodGet
,
"/api/token/"
+
strconv
.
Itoa
(
token
.
Id
),
nil
,
1
)
ctx
.
Params
=
gin
.
Params
{{
Key
:
"id"
,
Value
:
strconv
.
Itoa
(
token
.
Id
)}}
GetToken
(
ctx
)
response
:=
decodeAPIResponse
(
t
,
recorder
)
if
!
response
.
Success
{
t
.
Fatalf
(
"expected success response, got message: %s"
,
response
.
Message
)
}
var
detail
tokenResponseItem
if
err
:=
common
.
Unmarshal
(
response
.
Data
,
&
detail
);
err
!=
nil
{
t
.
Fatalf
(
"failed to decode token detail response: %v"
,
err
)
}
if
detail
.
Key
!=
token
.
GetMaskedKey
()
{
t
.
Fatalf
(
"expected masked detail key %q, got %q"
,
token
.
GetMaskedKey
(),
detail
.
Key
)
}
if
strings
.
Contains
(
recorder
.
Body
.
String
(),
token
.
Key
)
{
t
.
Fatalf
(
"detail response leaked raw token key: %s"
,
recorder
.
Body
.
String
())
}
}
func
TestUpdateTokenMasksKeyInResponse
(
t
*
testing
.
T
)
{
db
:=
setupTokenControllerTestDB
(
t
)
token
:=
seedToken
(
t
,
db
,
1
,
"editable-token"
,
"yzab1234cdef5678"
)
body
:=
map
[
string
]
any
{
"id"
:
token
.
Id
,
"name"
:
"updated-token"
,
"expired_time"
:
-
1
,
"remain_quota"
:
100
,
"unlimited_quota"
:
true
,
"model_limits_enabled"
:
false
,
"model_limits"
:
""
,
"group"
:
"default"
,
"cross_group_retry"
:
false
,
}
ctx
,
recorder
:=
newAuthenticatedContext
(
t
,
http
.
MethodPut
,
"/api/token/"
,
body
,
1
)
UpdateToken
(
ctx
)
response
:=
decodeAPIResponse
(
t
,
recorder
)
if
!
response
.
Success
{
t
.
Fatalf
(
"expected success response, got message: %s"
,
response
.
Message
)
}
var
detail
tokenResponseItem
if
err
:=
common
.
Unmarshal
(
response
.
Data
,
&
detail
);
err
!=
nil
{
t
.
Fatalf
(
"failed to decode token update response: %v"
,
err
)
}
if
detail
.
Key
!=
token
.
GetMaskedKey
()
{
t
.
Fatalf
(
"expected masked update key %q, got %q"
,
token
.
GetMaskedKey
(),
detail
.
Key
)
}
if
strings
.
Contains
(
recorder
.
Body
.
String
(),
token
.
Key
)
{
t
.
Fatalf
(
"update response leaked raw token key: %s"
,
recorder
.
Body
.
String
())
}
}
func
TestGetTokenKeyRequiresOwnershipAndReturnsFullKey
(
t
*
testing
.
T
)
{
db
:=
setupTokenControllerTestDB
(
t
)
token
:=
seedToken
(
t
,
db
,
1
,
"owned-token"
,
"owner1234token5678"
)
authorizedCtx
,
authorizedRecorder
:=
newAuthenticatedContext
(
t
,
http
.
MethodPost
,
"/api/token/"
+
strconv
.
Itoa
(
token
.
Id
)
+
"/key"
,
nil
,
1
)
authorizedCtx
.
Params
=
gin
.
Params
{{
Key
:
"id"
,
Value
:
strconv
.
Itoa
(
token
.
Id
)}}
GetTokenKey
(
authorizedCtx
)
authorizedResponse
:=
decodeAPIResponse
(
t
,
authorizedRecorder
)
if
!
authorizedResponse
.
Success
{
t
.
Fatalf
(
"expected authorized key fetch to succeed, got message: %s"
,
authorizedResponse
.
Message
)
}
var
keyData
tokenKeyResponse
if
err
:=
common
.
Unmarshal
(
authorizedResponse
.
Data
,
&
keyData
);
err
!=
nil
{
t
.
Fatalf
(
"failed to decode token key response: %v"
,
err
)
}
if
keyData
.
Key
!=
token
.
GetFullKey
()
{
t
.
Fatalf
(
"expected full key %q, got %q"
,
token
.
GetFullKey
(),
keyData
.
Key
)
}
unauthorizedCtx
,
unauthorizedRecorder
:=
newAuthenticatedContext
(
t
,
http
.
MethodPost
,
"/api/token/"
+
strconv
.
Itoa
(
token
.
Id
)
+
"/key"
,
nil
,
2
)
unauthorizedCtx
.
Params
=
gin
.
Params
{{
Key
:
"id"
,
Value
:
strconv
.
Itoa
(
token
.
Id
)}}
GetTokenKey
(
unauthorizedCtx
)
unauthorizedResponse
:=
decodeAPIResponse
(
t
,
unauthorizedRecorder
)
if
unauthorizedResponse
.
Success
{
t
.
Fatalf
(
"expected unauthorized key fetch to fail"
)
}
if
strings
.
Contains
(
unauthorizedRecorder
.
Body
.
String
(),
token
.
Key
)
{
t
.
Fatalf
(
"unauthorized key response leaked raw token key: %s"
,
unauthorizedRecorder
.
Body
.
String
())
}
}
model/token.go
View file @
d67f446b
...
...
@@ -35,6 +35,27 @@ func (token *Token) Clean() {
token
.
Key
=
""
}
func
MaskTokenKey
(
key
string
)
string
{
if
key
==
""
{
return
""
}
if
len
(
key
)
<=
4
{
return
strings
.
Repeat
(
"*"
,
len
(
key
))
}
if
len
(
key
)
<=
8
{
return
key
[
:
2
]
+
"****"
+
key
[
len
(
key
)
-
2
:
]
}
return
key
[
:
4
]
+
"**********"
+
key
[
len
(
key
)
-
4
:
]
}
func
(
token
*
Token
)
GetFullKey
()
string
{
return
token
.
Key
}
func
(
token
*
Token
)
GetMaskedKey
()
string
{
return
MaskTokenKey
(
token
.
Key
)
}
func
(
token
*
Token
)
GetIpLimits
()
[]
string
{
// delete empty spaces
//split with \n
...
...
@@ -201,7 +222,7 @@ func ValidateUserToken(key string) (token *Token, err error) {
}
keyPrefix
:=
key
[
:
3
]
keySuffix
:=
key
[
len
(
key
)
-
3
:
]
return
token
,
errors
.
New
(
fmt
.
Sprintf
(
"[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d"
,
keyPrefix
,
keySuffix
,
token
.
RemainQuota
)
)
return
token
,
fmt
.
Errorf
(
"[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d"
,
keyPrefix
,
keySuffix
,
token
.
RemainQuota
)
}
return
token
,
nil
}
...
...
router/api-router.go
View file @
d67f446b
...
...
@@ -248,6 +248,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute
.
GET
(
"/"
,
controller
.
GetAllTokens
)
tokenRoute
.
GET
(
"/search"
,
middleware
.
SearchRateLimit
(),
controller
.
SearchTokens
)
tokenRoute
.
GET
(
"/:id"
,
controller
.
GetToken
)
tokenRoute
.
POST
(
"/:id/key"
,
middleware
.
CriticalRateLimit
(),
middleware
.
DisableCache
(),
controller
.
GetTokenKey
)
tokenRoute
.
POST
(
"/"
,
controller
.
AddToken
)
tokenRoute
.
PUT
(
"/"
,
controller
.
UpdateToken
)
tokenRoute
.
DELETE
(
"/:id"
,
controller
.
DeleteToken
)
...
...
web/src/components/table/tokens/TokensActions.jsx
View file @
d67f446b
...
...
@@ -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
}
/>
...
...
web/src/components/table/tokens/TokensColumnDefs.jsx
View file @
d67f446b
...
...
@@ -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
:
mask
edKey
}
value=
{
display
edKey
}
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
copyT
ext
(
fullKey
);
await
copyT
okenKey
(
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
(
'可用模型'
),
...
...
web/src/components/table/tokens/TokensTable.jsx
View file @
d67f446b
...
...
@@ -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
,
...
...
web/src/components/table/tokens/index.jsx
View file @
d67f446b
...
...
@@ -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
}
/>
...
...
web/src/components/table/tokens/modals/CCSwitchModal.jsx
View file @
d67f446b
...
...
@@ -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
();
};
...
...
web/src/components/table/tokens/modals/CopyTokensModal.jsx
View file @
d67f446b
...
...
@@ -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
();
};
...
...
web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx
View file @
d67f446b
...
...
@@ -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
(
'价格模式(默认)'
)
}
...
...
web/src/helpers/token.js
View file @
d67f446b
...
...
@@ -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
[];
...
...
web/src/hooks/tokens/useTokensData.jsx
View file @
d67f446b
...
...
@@ -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
.
k
ey
);
openCCSwitchModal
(
fullK
ey
);
return
;
}
if
(
url
&&
url
.
startsWith
(
'fluent'
))
{
openFluentNotification
(
record
.
k
ey
);
openFluentNotification
(
fullK
ey
);
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
()
=>
{
try
{
const
keys
=
await
Promise
.
all
(
selectedKeys
.
map
((
token
)
=>
fetchTokenKey
(
token
,
{
suppressError
:
true
})),
);
let
content
=
''
;
for
(
let
i
=
0
;
i
<
selectedKeys
.
length
;
i
++
)
{
content
+=
selectedKeys
[
i
].
name
+
' sk-'
+
selectedKeys
[
i
].
key
+
'\n'
;
const
fullKey
=
keys
[
i
];
if
(
copyType
===
'name+key'
)
{
content
+=
`
${
selectedKeys
[
i
].
name
}
sk-
${
fullKey
}
\n`
;
}
else
{
content
+=
`sk-
${
fullKey
}
\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
>
),
});
}
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
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment