Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
bdc426a7
Commit
bdc426a7
authored
Jan 18, 2026
by
yangjianbo
Browse files
Merge branch 'main' into dev
parents
771baa66
32fff379
Changes
44
Show whitespace changes
Inline
Side-by-side
backend/internal/service/gateway_multiplatform_test.go
View file @
bdc426a7
...
@@ -1052,7 +1052,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1052,7 +1052,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
nil
,
// No concurrency service
concurrencyService
:
nil
,
// No concurrency service
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
@@ -1105,7 +1105,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1105,7 +1105,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
nil
,
// legacy path
concurrencyService
:
nil
,
// legacy path
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
&
groupID
,
sessionHash
,
"claude-b"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
&
groupID
,
sessionHash
,
"claude-b"
,
nil
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
@@ -1137,7 +1137,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1137,7 +1137,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
nil
,
concurrencyService
:
nil
,
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
@@ -1169,7 +1169,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1169,7 +1169,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
}
}
excludedIDs
:=
map
[
int64
]
struct
{}{
1
:
{}}
excludedIDs
:=
map
[
int64
]
struct
{}{
1
:
{}}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
excludedIDs
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
excludedIDs
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
@@ -1203,7 +1203,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1203,7 +1203,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
NewConcurrencyService
(
concurrencyCache
),
concurrencyService
:
NewConcurrencyService
(
concurrencyCache
),
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
"sticky"
,
"claude-3-5-sonnet-20241022"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
"sticky"
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
@@ -1239,7 +1239,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1239,7 +1239,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
NewConcurrencyService
(
concurrencyCache
),
concurrencyService
:
NewConcurrencyService
(
concurrencyCache
),
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
"sticky"
,
"claude-3-5-sonnet-20241022"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
"sticky"
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
@@ -1266,7 +1266,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1266,7 +1266,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
nil
,
concurrencyService
:
nil
,
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
)
require
.
Error
(
t
,
err
)
require
.
Error
(
t
,
err
)
require
.
Nil
(
t
,
result
)
require
.
Nil
(
t
,
result
)
require
.
Contains
(
t
,
err
.
Error
(),
"no available accounts"
)
require
.
Contains
(
t
,
err
.
Error
(),
"no available accounts"
)
...
@@ -1298,7 +1298,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1298,7 +1298,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
nil
,
concurrencyService
:
nil
,
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
@@ -1331,7 +1331,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
...
@@ -1331,7 +1331,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
concurrencyService
:
nil
,
concurrencyService
:
nil
,
}
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
,
""
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
NotNil
(
t
,
result
.
Account
)
...
...
backend/internal/service/gateway_service.go
View file @
bdc426a7
...
@@ -176,6 +176,7 @@ type GatewayService struct {
...
@@ -176,6 +176,7 @@ type GatewayService struct {
deferredService
*
DeferredService
deferredService
*
DeferredService
concurrencyService
*
ConcurrencyService
concurrencyService
*
ConcurrencyService
claudeTokenProvider
*
ClaudeTokenProvider
claudeTokenProvider
*
ClaudeTokenProvider
sessionLimitCache
SessionLimitCache
// 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
}
}
// NewGatewayService creates a new GatewayService
// NewGatewayService creates a new GatewayService
...
@@ -196,6 +197,7 @@ func NewGatewayService(
...
@@ -196,6 +197,7 @@ func NewGatewayService(
httpUpstream
HTTPUpstream
,
httpUpstream
HTTPUpstream
,
deferredService
*
DeferredService
,
deferredService
*
DeferredService
,
claudeTokenProvider
*
ClaudeTokenProvider
,
claudeTokenProvider
*
ClaudeTokenProvider
,
sessionLimitCache
SessionLimitCache
,
)
*
GatewayService
{
)
*
GatewayService
{
return
&
GatewayService
{
return
&
GatewayService
{
accountRepo
:
accountRepo
,
accountRepo
:
accountRepo
,
...
@@ -214,6 +216,7 @@ func NewGatewayService(
...
@@ -214,6 +216,7 @@ func NewGatewayService(
httpUpstream
:
httpUpstream
,
httpUpstream
:
httpUpstream
,
deferredService
:
deferredService
,
deferredService
:
deferredService
,
claudeTokenProvider
:
claudeTokenProvider
,
claudeTokenProvider
:
claudeTokenProvider
,
sessionLimitCache
:
sessionLimitCache
,
}
}
}
}
...
@@ -407,8 +410,12 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
...
@@ -407,8 +410,12 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
}
}
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
func
(
s
*
GatewayService
)
SelectAccountWithLoadAwareness
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{})
(
*
AccountSelectionResult
,
error
)
{
// metadataUserID: 原始 metadata.user_id 字段(用于提取会话 UUID 进行会话数量限制)
func
(
s
*
GatewayService
)
SelectAccountWithLoadAwareness
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
metadataUserID
string
)
(
*
AccountSelectionResult
,
error
)
{
cfg
:=
s
.
schedulingConfig
()
cfg
:=
s
.
schedulingConfig
()
// 提取会话 UUID(用于会话数量限制)
sessionUUID
:=
extractSessionUUID
(
metadataUserID
)
var
stickyAccountID
int64
var
stickyAccountID
int64
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
if
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
);
err
==
nil
{
if
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
);
err
==
nil
{
...
@@ -527,7 +534,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -527,7 +534,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if
len
(
routingAccountIDs
)
>
0
&&
s
.
concurrencyService
!=
nil
{
if
len
(
routingAccountIDs
)
>
0
&&
s
.
concurrencyService
!=
nil
{
// 1. 过滤出路由列表中可调度的账号
// 1. 过滤出路由列表中可调度的账号
var
routingCandidates
[]
*
Account
var
routingCandidates
[]
*
Account
var
filteredExcluded
,
filteredMissing
,
filteredUnsched
,
filteredPlatform
,
filteredModelScope
,
filteredModelMapping
int
var
filteredExcluded
,
filteredMissing
,
filteredUnsched
,
filteredPlatform
,
filteredModelScope
,
filteredModelMapping
,
filteredWindowCost
int
for
_
,
routingAccountID
:=
range
routingAccountIDs
{
for
_
,
routingAccountID
:=
range
routingAccountIDs
{
if
isExcluded
(
routingAccountID
)
{
if
isExcluded
(
routingAccountID
)
{
filteredExcluded
++
filteredExcluded
++
...
@@ -554,13 +561,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -554,13 +561,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
filteredModelMapping
++
filteredModelMapping
++
continue
continue
}
}
// 窗口费用检查(非粘性会话路径)
if
!
s
.
isAccountSchedulableForWindowCost
(
ctx
,
account
,
false
)
{
filteredWindowCost
++
continue
}
routingCandidates
=
append
(
routingCandidates
,
account
)
routingCandidates
=
append
(
routingCandidates
,
account
)
}
}
if
s
.
debugModelRoutingEnabled
()
{
if
s
.
debugModelRoutingEnabled
()
{
log
.
Printf
(
"[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d)"
,
log
.
Printf
(
"[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d
window_cost=%d
)"
,
derefGroupID
(
groupID
),
requestedModel
,
len
(
routingAccountIDs
),
len
(
routingCandidates
),
derefGroupID
(
groupID
),
requestedModel
,
len
(
routingAccountIDs
),
len
(
routingCandidates
),
filteredExcluded
,
filteredMissing
,
filteredUnsched
,
filteredPlatform
,
filteredModelScope
,
filteredModelMapping
)
filteredExcluded
,
filteredMissing
,
filteredUnsched
,
filteredPlatform
,
filteredModelScope
,
filteredModelMapping
,
filteredWindowCost
)
}
}
if
len
(
routingCandidates
)
>
0
{
if
len
(
routingCandidates
)
>
0
{
...
@@ -573,9 +585,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -573,9 +585,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if
stickyAccount
.
IsSchedulable
()
&&
if
stickyAccount
.
IsSchedulable
()
&&
s
.
isAccountAllowedForPlatform
(
stickyAccount
,
platform
,
useMixed
)
&&
s
.
isAccountAllowedForPlatform
(
stickyAccount
,
platform
,
useMixed
)
&&
stickyAccount
.
IsSchedulableForModel
(
requestedModel
)
&&
stickyAccount
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
stickyAccount
,
requestedModel
))
{
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
stickyAccount
,
requestedModel
))
&&
s
.
isAccountSchedulableForWindowCost
(
ctx
,
stickyAccount
,
true
)
{
// 粘性会话窗口费用检查
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
stickyAccountID
,
stickyAccount
.
Concurrency
)
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
stickyAccountID
,
stickyAccount
.
Concurrency
)
if
err
==
nil
&&
result
.
Acquired
{
if
err
==
nil
&&
result
.
Acquired
{
// 会话数量限制检查
if
!
s
.
checkAndRegisterSession
(
ctx
,
stickyAccount
,
sessionUUID
)
{
result
.
ReleaseFunc
()
// 释放槽位
// 继续到负载感知选择
}
else
{
_
=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
stickySessionTTL
)
_
=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
stickySessionTTL
)
if
s
.
debugModelRoutingEnabled
()
{
if
s
.
debugModelRoutingEnabled
()
{
log
.
Printf
(
"[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d"
,
derefGroupID
(
groupID
),
requestedModel
,
shortSessionHash
(
sessionHash
),
stickyAccountID
)
log
.
Printf
(
"[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d"
,
derefGroupID
(
groupID
),
requestedModel
,
shortSessionHash
(
sessionHash
),
stickyAccountID
)
...
@@ -586,6 +604,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -586,6 +604,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
ReleaseFunc
:
result
.
ReleaseFunc
,
ReleaseFunc
:
result
.
ReleaseFunc
,
},
nil
},
nil
}
}
}
waitingCount
,
_
:=
s
.
concurrencyService
.
GetAccountWaitingCount
(
ctx
,
stickyAccountID
)
waitingCount
,
_
:=
s
.
concurrencyService
.
GetAccountWaitingCount
(
ctx
,
stickyAccountID
)
if
waitingCount
<
cfg
.
StickySessionMaxWaiting
{
if
waitingCount
<
cfg
.
StickySessionMaxWaiting
{
...
@@ -657,6 +676,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -657,6 +676,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
for
_
,
item
:=
range
routingAvailable
{
for
_
,
item
:=
range
routingAvailable
{
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
item
.
account
.
ID
,
item
.
account
.
Concurrency
)
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
item
.
account
.
ID
,
item
.
account
.
Concurrency
)
if
err
==
nil
&&
result
.
Acquired
{
if
err
==
nil
&&
result
.
Acquired
{
// 会话数量限制检查
if
!
s
.
checkAndRegisterSession
(
ctx
,
item
.
account
,
sessionUUID
)
{
result
.
ReleaseFunc
()
// 释放槽位,继续尝试下一个账号
continue
}
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
_
=
s
.
cache
.
SetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
item
.
account
.
ID
,
stickySessionTTL
)
_
=
s
.
cache
.
SetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
item
.
account
.
ID
,
stickySessionTTL
)
}
}
...
@@ -699,9 +723,14 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -699,9 +723,14 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if
ok
&&
s
.
isAccountInGroup
(
account
,
groupID
)
&&
if
ok
&&
s
.
isAccountInGroup
(
account
,
groupID
)
&&
s
.
isAccountAllowedForPlatform
(
account
,
platform
,
useMixed
)
&&
s
.
isAccountAllowedForPlatform
(
account
,
platform
,
useMixed
)
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
{
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
&&
s
.
isAccountSchedulableForWindowCost
(
ctx
,
account
,
true
)
{
// 粘性会话窗口费用检查
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
accountID
,
account
.
Concurrency
)
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
accountID
,
account
.
Concurrency
)
if
err
==
nil
&&
result
.
Acquired
{
if
err
==
nil
&&
result
.
Acquired
{
// 会话数量限制检查
if
!
s
.
checkAndRegisterSession
(
ctx
,
account
,
sessionUUID
)
{
result
.
ReleaseFunc
()
// 释放槽位,继续到 Layer 2
}
else
{
_
=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
stickySessionTTL
)
_
=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
stickySessionTTL
)
return
&
AccountSelectionResult
{
return
&
AccountSelectionResult
{
Account
:
account
,
Account
:
account
,
...
@@ -709,6 +738,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -709,6 +738,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
ReleaseFunc
:
result
.
ReleaseFunc
,
ReleaseFunc
:
result
.
ReleaseFunc
,
},
nil
},
nil
}
}
}
waitingCount
,
_
:=
s
.
concurrencyService
.
GetAccountWaitingCount
(
ctx
,
accountID
)
waitingCount
,
_
:=
s
.
concurrencyService
.
GetAccountWaitingCount
(
ctx
,
accountID
)
if
waitingCount
<
cfg
.
StickySessionMaxWaiting
{
if
waitingCount
<
cfg
.
StickySessionMaxWaiting
{
...
@@ -748,6 +778,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -748,6 +778,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if
requestedModel
!=
""
&&
!
s
.
isModelSupportedByAccount
(
acc
,
requestedModel
)
{
if
requestedModel
!=
""
&&
!
s
.
isModelSupportedByAccount
(
acc
,
requestedModel
)
{
continue
continue
}
}
// 窗口费用检查(非粘性会话路径)
if
!
s
.
isAccountSchedulableForWindowCost
(
ctx
,
acc
,
false
)
{
continue
}
candidates
=
append
(
candidates
,
acc
)
candidates
=
append
(
candidates
,
acc
)
}
}
...
@@ -765,7 +799,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -765,7 +799,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
loadMap
,
err
:=
s
.
concurrencyService
.
GetAccountsLoadBatch
(
ctx
,
accountLoads
)
loadMap
,
err
:=
s
.
concurrencyService
.
GetAccountsLoadBatch
(
ctx
,
accountLoads
)
if
err
!=
nil
{
if
err
!=
nil
{
if
result
,
ok
:=
s
.
tryAcquireByLegacyOrder
(
ctx
,
candidates
,
groupID
,
sessionHash
,
preferOAuth
);
ok
{
if
result
,
ok
:=
s
.
tryAcquireByLegacyOrder
(
ctx
,
candidates
,
groupID
,
sessionHash
,
preferOAuth
,
sessionUUID
);
ok
{
return
result
,
nil
return
result
,
nil
}
}
}
else
{
}
else
{
...
@@ -814,6 +848,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -814,6 +848,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
for
_
,
item
:=
range
available
{
for
_
,
item
:=
range
available
{
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
item
.
account
.
ID
,
item
.
account
.
Concurrency
)
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
item
.
account
.
ID
,
item
.
account
.
Concurrency
)
if
err
==
nil
&&
result
.
Acquired
{
if
err
==
nil
&&
result
.
Acquired
{
// 会话数量限制检查
if
!
s
.
checkAndRegisterSession
(
ctx
,
item
.
account
,
sessionUUID
)
{
result
.
ReleaseFunc
()
// 释放槽位,继续尝试下一个账号
continue
}
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
_
=
s
.
cache
.
SetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
item
.
account
.
ID
,
stickySessionTTL
)
_
=
s
.
cache
.
SetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
item
.
account
.
ID
,
stickySessionTTL
)
}
}
...
@@ -843,13 +882,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -843,13 +882,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
return
nil
,
errors
.
New
(
"no available accounts"
)
return
nil
,
errors
.
New
(
"no available accounts"
)
}
}
func
(
s
*
GatewayService
)
tryAcquireByLegacyOrder
(
ctx
context
.
Context
,
candidates
[]
*
Account
,
groupID
*
int64
,
sessionHash
string
,
preferOAuth
bool
)
(
*
AccountSelectionResult
,
bool
)
{
func
(
s
*
GatewayService
)
tryAcquireByLegacyOrder
(
ctx
context
.
Context
,
candidates
[]
*
Account
,
groupID
*
int64
,
sessionHash
string
,
preferOAuth
bool
,
sessionUUID
string
)
(
*
AccountSelectionResult
,
bool
)
{
ordered
:=
append
([]
*
Account
(
nil
),
candidates
...
)
ordered
:=
append
([]
*
Account
(
nil
),
candidates
...
)
sortAccountsByPriorityAndLastUsed
(
ordered
,
preferOAuth
)
sortAccountsByPriorityAndLastUsed
(
ordered
,
preferOAuth
)
for
_
,
acc
:=
range
ordered
{
for
_
,
acc
:=
range
ordered
{
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
acc
.
ID
,
acc
.
Concurrency
)
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
acc
.
ID
,
acc
.
Concurrency
)
if
err
==
nil
&&
result
.
Acquired
{
if
err
==
nil
&&
result
.
Acquired
{
// 会话数量限制检查
if
!
s
.
checkAndRegisterSession
(
ctx
,
acc
,
sessionUUID
)
{
result
.
ReleaseFunc
()
// 释放槽位,继续尝试下一个账号
continue
}
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
if
sessionHash
!=
""
&&
s
.
cache
!=
nil
{
_
=
s
.
cache
.
SetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
acc
.
ID
,
stickySessionTTL
)
_
=
s
.
cache
.
SetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
acc
.
ID
,
stickySessionTTL
)
}
}
...
@@ -1081,6 +1125,107 @@ func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID in
...
@@ -1081,6 +1125,107 @@ func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID in
return
s
.
concurrencyService
.
AcquireAccountSlot
(
ctx
,
accountID
,
maxConcurrency
)
return
s
.
concurrencyService
.
AcquireAccountSlot
(
ctx
,
accountID
,
maxConcurrency
)
}
}
// isAccountSchedulableForWindowCost 检查账号是否可根据窗口费用进行调度
// 仅适用于 Anthropic OAuth/SetupToken 账号
// 返回 true 表示可调度,false 表示不可调度
func
(
s
*
GatewayService
)
isAccountSchedulableForWindowCost
(
ctx
context
.
Context
,
account
*
Account
,
isSticky
bool
)
bool
{
// 只检查 Anthropic OAuth/SetupToken 账号
if
!
account
.
IsAnthropicOAuthOrSetupToken
()
{
return
true
}
limit
:=
account
.
GetWindowCostLimit
()
if
limit
<=
0
{
return
true
// 未启用窗口费用限制
}
// 尝试从缓存获取窗口费用
var
currentCost
float64
if
s
.
sessionLimitCache
!=
nil
{
if
cost
,
hit
,
err
:=
s
.
sessionLimitCache
.
GetWindowCost
(
ctx
,
account
.
ID
);
err
==
nil
&&
hit
{
currentCost
=
cost
goto
checkSchedulability
}
}
// 缓存未命中,从数据库查询
{
var
startTime
time
.
Time
if
account
.
SessionWindowStart
!=
nil
{
startTime
=
*
account
.
SessionWindowStart
}
else
{
startTime
=
time
.
Now
()
.
Add
(
-
5
*
time
.
Hour
)
}
stats
,
err
:=
s
.
usageLogRepo
.
GetAccountWindowStats
(
ctx
,
account
.
ID
,
startTime
)
if
err
!=
nil
{
// 失败开放:查询失败时允许调度
return
true
}
// 使用标准费用(不含账号倍率)
currentCost
=
stats
.
StandardCost
// 设置缓存(忽略错误)
if
s
.
sessionLimitCache
!=
nil
{
_
=
s
.
sessionLimitCache
.
SetWindowCost
(
ctx
,
account
.
ID
,
currentCost
)
}
}
checkSchedulability
:
schedulability
:=
account
.
CheckWindowCostSchedulability
(
currentCost
)
switch
schedulability
{
case
WindowCostSchedulable
:
return
true
case
WindowCostStickyOnly
:
return
isSticky
case
WindowCostNotSchedulable
:
return
false
}
return
true
}
// checkAndRegisterSession 检查并注册会话,用于会话数量限制
// 仅适用于 Anthropic OAuth/SetupToken 账号
// 返回 true 表示允许(在限制内或会话已存在),false 表示拒绝(超出限制且是新会话)
func
(
s
*
GatewayService
)
checkAndRegisterSession
(
ctx
context
.
Context
,
account
*
Account
,
sessionUUID
string
)
bool
{
// 只检查 Anthropic OAuth/SetupToken 账号
if
!
account
.
IsAnthropicOAuthOrSetupToken
()
{
return
true
}
maxSessions
:=
account
.
GetMaxSessions
()
if
maxSessions
<=
0
||
sessionUUID
==
""
{
return
true
// 未启用会话限制或无会话ID
}
if
s
.
sessionLimitCache
==
nil
{
return
true
// 缓存不可用时允许通过
}
idleTimeout
:=
time
.
Duration
(
account
.
GetSessionIdleTimeoutMinutes
())
*
time
.
Minute
allowed
,
err
:=
s
.
sessionLimitCache
.
RegisterSession
(
ctx
,
account
.
ID
,
sessionUUID
,
maxSessions
,
idleTimeout
)
if
err
!=
nil
{
// 失败开放:缓存错误时允许通过
return
true
}
return
allowed
}
// extractSessionUUID 从 metadata.user_id 中提取会话 UUID
// 格式: user_{64位hex}_account__session_{uuid}
func
extractSessionUUID
(
metadataUserID
string
)
string
{
if
metadataUserID
==
""
{
return
""
}
if
match
:=
sessionIDRegex
.
FindStringSubmatch
(
metadataUserID
);
len
(
match
)
>
1
{
return
match
[
1
]
}
return
""
}
func
(
s
*
GatewayService
)
getSchedulableAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
Account
,
error
)
{
func
(
s
*
GatewayService
)
getSchedulableAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
Account
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
if
s
.
schedulerSnapshot
!=
nil
{
return
s
.
schedulerSnapshot
.
GetAccount
(
ctx
,
accountID
)
return
s
.
schedulerSnapshot
.
GetAccount
(
ctx
,
accountID
)
...
...
backend/internal/service/gemini_multiplatform_test.go
View file @
bdc426a7
...
@@ -599,7 +599,7 @@ func TestGeminiMessagesCompatService_isModelSupportedByAccount(t *testing.T) {
...
@@ -599,7 +599,7 @@ func TestGeminiMessagesCompatService_isModelSupportedByAccount(t *testing.T) {
name
:
"Gemini平台-有映射配置-只支持配置的模型"
,
name
:
"Gemini平台-有映射配置-只支持配置的模型"
,
account
:
&
Account
{
account
:
&
Account
{
Platform
:
PlatformGemini
,
Platform
:
PlatformGemini
,
Credentials
:
map
[
string
]
any
{
"model_mapping"
:
map
[
string
]
any
{
"gemini-
1
.5-pro"
:
"x"
}},
Credentials
:
map
[
string
]
any
{
"model_mapping"
:
map
[
string
]
any
{
"gemini-
2
.5-pro"
:
"x"
}},
},
},
model
:
"gemini-2.5-flash"
,
model
:
"gemini-2.5-flash"
,
expected
:
false
,
expected
:
false
,
...
...
backend/internal/service/openai_codex_transform.go
View file @
bdc426a7
...
@@ -394,19 +394,35 @@ func normalizeCodexTools(reqBody map[string]any) bool {
...
@@ -394,19 +394,35 @@ func normalizeCodexTools(reqBody map[string]any) bool {
}
}
modified
:=
false
modified
:=
false
for
idx
,
tool
:=
range
tools
{
validTools
:=
make
([]
any
,
0
,
len
(
tools
))
for
_
,
tool
:=
range
tools
{
toolMap
,
ok
:=
tool
.
(
map
[
string
]
any
)
toolMap
,
ok
:=
tool
.
(
map
[
string
]
any
)
if
!
ok
{
if
!
ok
{
// Keep unknown structure as-is to avoid breaking upstream behavior.
validTools
=
append
(
validTools
,
tool
)
continue
continue
}
}
toolType
,
_
:=
toolMap
[
"type"
]
.
(
string
)
toolType
,
_
:=
toolMap
[
"type"
]
.
(
string
)
if
strings
.
TrimSpace
(
toolType
)
!=
"function"
{
toolType
=
strings
.
TrimSpace
(
toolType
)
if
toolType
!=
"function"
{
validTools
=
append
(
validTools
,
toolMap
)
continue
continue
}
}
function
,
ok
:=
toolMap
[
"function"
]
.
(
map
[
string
]
any
)
// OpenAI Responses-style tools use top-level name/parameters.
if
!
ok
{
if
name
,
ok
:=
toolMap
[
"name"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
name
)
!=
""
{
validTools
=
append
(
validTools
,
toolMap
)
continue
}
// ChatCompletions-style tools use {type:"function", function:{...}}.
functionValue
,
hasFunction
:=
toolMap
[
"function"
]
function
,
ok
:=
functionValue
.
(
map
[
string
]
any
)
if
!
hasFunction
||
functionValue
==
nil
||
!
ok
||
function
==
nil
{
// Drop invalid function tools.
modified
=
true
continue
continue
}
}
...
@@ -435,11 +451,11 @@ func normalizeCodexTools(reqBody map[string]any) bool {
...
@@ -435,11 +451,11 @@ func normalizeCodexTools(reqBody map[string]any) bool {
}
}
}
}
tools
[
idx
]
=
toolMap
validTools
=
append
(
validTools
,
toolMap
)
}
}
if
modified
{
if
modified
{
reqBody
[
"tools"
]
=
t
ools
reqBody
[
"tools"
]
=
validT
ools
}
}
return
modified
return
modified
...
...
backend/internal/service/openai_codex_transform_test.go
View file @
bdc426a7
...
@@ -129,6 +129,37 @@ func TestFilterCodexInput_RemovesItemReferenceWhenNotPreserved(t *testing.T) {
...
@@ -129,6 +129,37 @@ func TestFilterCodexInput_RemovesItemReferenceWhenNotPreserved(t *testing.T) {
require
.
False
(
t
,
hasID
)
require
.
False
(
t
,
hasID
)
}
}
func
TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunctionTools
(
t
*
testing
.
T
)
{
setupCodexCache
(
t
)
reqBody
:=
map
[
string
]
any
{
"model"
:
"gpt-5.1"
,
"tools"
:
[]
any
{
map
[
string
]
any
{
"type"
:
"function"
,
"name"
:
"bash"
,
"description"
:
"desc"
,
"parameters"
:
map
[
string
]
any
{
"type"
:
"object"
},
},
map
[
string
]
any
{
"type"
:
"function"
,
"function"
:
nil
,
},
},
}
applyCodexOAuthTransform
(
reqBody
)
tools
,
ok
:=
reqBody
[
"tools"
]
.
([]
any
)
require
.
True
(
t
,
ok
)
require
.
Len
(
t
,
tools
,
1
)
first
,
ok
:=
tools
[
0
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"function"
,
first
[
"type"
])
require
.
Equal
(
t
,
"bash"
,
first
[
"name"
])
}
func
TestApplyCodexOAuthTransform_EmptyInput
(
t
*
testing
.
T
)
{
func
TestApplyCodexOAuthTransform_EmptyInput
(
t
*
testing
.
T
)
{
// 空 input 应保持为空且不触发异常。
// 空 input 应保持为空且不触发异常。
setupCodexCache
(
t
)
setupCodexCache
(
t
)
...
...
backend/internal/service/openai_gateway_service.go
View file @
bdc426a7
...
@@ -133,12 +133,30 @@ func NewOpenAIGatewayService(
...
@@ -133,12 +133,30 @@ func NewOpenAIGatewayService(
}
}
}
}
// GenerateSessionHash generates session hash from header (OpenAI uses session_id header)
// GenerateSessionHash generates a sticky-session hash for OpenAI requests.
func
(
s
*
OpenAIGatewayService
)
GenerateSessionHash
(
c
*
gin
.
Context
)
string
{
//
sessionID
:=
c
.
GetHeader
(
"session_id"
)
// Priority:
// 1. Header: session_id
// 2. Header: conversation_id
// 3. Body: prompt_cache_key (opencode)
func
(
s
*
OpenAIGatewayService
)
GenerateSessionHash
(
c
*
gin
.
Context
,
reqBody
map
[
string
]
any
)
string
{
if
c
==
nil
{
return
""
}
sessionID
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"session_id"
))
if
sessionID
==
""
{
sessionID
=
strings
.
TrimSpace
(
c
.
GetHeader
(
"conversation_id"
))
}
if
sessionID
==
""
&&
reqBody
!=
nil
{
if
v
,
ok
:=
reqBody
[
"prompt_cache_key"
]
.
(
string
);
ok
{
sessionID
=
strings
.
TrimSpace
(
v
)
}
}
if
sessionID
==
""
{
if
sessionID
==
""
{
return
""
return
""
}
}
hash
:=
sha256
.
Sum256
([]
byte
(
sessionID
))
hash
:=
sha256
.
Sum256
([]
byte
(
sessionID
))
return
hex
.
EncodeToString
(
hash
[
:
])
return
hex
.
EncodeToString
(
hash
[
:
])
}
}
...
...
backend/internal/service/openai_gateway_service_test.go
View file @
bdc426a7
...
@@ -49,6 +49,49 @@ func (c stubConcurrencyCache) GetAccountsLoadBatch(ctx context.Context, accounts
...
@@ -49,6 +49,49 @@ func (c stubConcurrencyCache) GetAccountsLoadBatch(ctx context.Context, accounts
return
out
,
nil
return
out
,
nil
}
}
func
TestOpenAIGatewayService_GenerateSessionHash_Priority
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/openai/v1/responses"
,
nil
)
svc
:=
&
OpenAIGatewayService
{}
// 1) session_id header wins
c
.
Request
.
Header
.
Set
(
"session_id"
,
"sess-123"
)
c
.
Request
.
Header
.
Set
(
"conversation_id"
,
"conv-456"
)
h1
:=
svc
.
GenerateSessionHash
(
c
,
map
[
string
]
any
{
"prompt_cache_key"
:
"ses_aaa"
})
if
h1
==
""
{
t
.
Fatalf
(
"expected non-empty hash"
)
}
// 2) conversation_id used when session_id absent
c
.
Request
.
Header
.
Del
(
"session_id"
)
h2
:=
svc
.
GenerateSessionHash
(
c
,
map
[
string
]
any
{
"prompt_cache_key"
:
"ses_aaa"
})
if
h2
==
""
{
t
.
Fatalf
(
"expected non-empty hash"
)
}
if
h1
==
h2
{
t
.
Fatalf
(
"expected different hashes for different keys"
)
}
// 3) prompt_cache_key used when both headers absent
c
.
Request
.
Header
.
Del
(
"conversation_id"
)
h3
:=
svc
.
GenerateSessionHash
(
c
,
map
[
string
]
any
{
"prompt_cache_key"
:
"ses_aaa"
})
if
h3
==
""
{
t
.
Fatalf
(
"expected non-empty hash"
)
}
if
h2
==
h3
{
t
.
Fatalf
(
"expected different hashes for different keys"
)
}
// 4) empty when no signals
h4
:=
svc
.
GenerateSessionHash
(
c
,
map
[
string
]
any
{})
if
h4
!=
""
{
t
.
Fatalf
(
"expected empty hash when no signals"
)
}
}
func
TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable
(
t
*
testing
.
T
)
{
func
TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
...
...
backend/internal/service/openai_tool_corrector.go
View file @
bdc426a7
...
@@ -27,6 +27,11 @@ var codexToolNameMapping = map[string]string{
...
@@ -27,6 +27,11 @@ var codexToolNameMapping = map[string]string{
"executeBash"
:
"bash"
,
"executeBash"
:
"bash"
,
"exec_bash"
:
"bash"
,
"exec_bash"
:
"bash"
,
"execBash"
:
"bash"
,
"execBash"
:
"bash"
,
// Some clients output generic fetch names.
"fetch"
:
"webfetch"
,
"web_fetch"
:
"webfetch"
,
"webFetch"
:
"webfetch"
,
}
}
// ToolCorrectionStats 记录工具修正的统计信息(导出用于 JSON 序列化)
// ToolCorrectionStats 记录工具修正的统计信息(导出用于 JSON 序列化)
...
@@ -208,27 +213,67 @@ func (c *CodexToolCorrector) correctToolParameters(toolName string, functionCall
...
@@ -208,27 +213,67 @@ func (c *CodexToolCorrector) correctToolParameters(toolName string, functionCall
// 根据工具名称应用特定的参数修正规则
// 根据工具名称应用特定的参数修正规则
switch
toolName
{
switch
toolName
{
case
"bash"
:
case
"bash"
:
// 移除 workdir 参数(OpenCode 不支持)
// OpenCode bash 支持 workdir;有些来源会输出 work_dir。
if
_
,
exists
:=
argsMap
[
"workdir"
];
exists
{
if
_
,
hasWorkdir
:=
argsMap
[
"workdir"
];
!
hasWorkdir
{
delete
(
argsMap
,
"workdir"
)
if
workDir
,
exists
:=
argsMap
[
"work_dir"
];
exists
{
argsMap
[
"workdir"
]
=
workDir
delete
(
argsMap
,
"work_dir"
)
corrected
=
true
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Re
mov
ed 'workdir'
parameter from
bash tool"
)
log
.
Printf
(
"[CodexToolCorrector] Re
nam
ed 'work
_
dir'
to 'workdir' in
bash tool"
)
}
}
}
else
{
if
_
,
exists
:=
argsMap
[
"work_dir"
];
exists
{
if
_
,
exists
:=
argsMap
[
"work_dir"
];
exists
{
delete
(
argsMap
,
"work_dir"
)
delete
(
argsMap
,
"work_dir"
)
corrected
=
true
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Removed 'work_dir' parameter from bash tool"
)
log
.
Printf
(
"[CodexToolCorrector] Removed duplicate 'work_dir' parameter from bash tool"
)
}
}
}
case
"edit"
:
case
"edit"
:
// OpenCode edit 使用 old_string/new_string,Codex 可能使用其他名称
// OpenCode edit 参数为 filePath/oldString/newString(camelCase)。
// 这里可以添加参数名称的映射逻辑
if
_
,
exists
:=
argsMap
[
"filePath"
];
!
exists
{
if
_
,
exists
:=
argsMap
[
"file_path"
];
!
exists
{
if
filePath
,
exists
:=
argsMap
[
"file_path"
];
exists
{
if
path
,
exists
:=
argsMap
[
"path"
];
exists
{
argsMap
[
"filePath"
]
=
filePath
argsMap
[
"file_path"
]
=
path
delete
(
argsMap
,
"file_path"
)
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Renamed 'file_path' to 'filePath' in edit tool"
)
}
else
if
filePath
,
exists
:=
argsMap
[
"path"
];
exists
{
argsMap
[
"filePath"
]
=
filePath
delete
(
argsMap
,
"path"
)
delete
(
argsMap
,
"path"
)
corrected
=
true
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Renamed 'path' to 'file_path' in edit tool"
)
log
.
Printf
(
"[CodexToolCorrector] Renamed 'path' to 'filePath' in edit tool"
)
}
else
if
filePath
,
exists
:=
argsMap
[
"file"
];
exists
{
argsMap
[
"filePath"
]
=
filePath
delete
(
argsMap
,
"file"
)
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Renamed 'file' to 'filePath' in edit tool"
)
}
}
if
_
,
exists
:=
argsMap
[
"oldString"
];
!
exists
{
if
oldString
,
exists
:=
argsMap
[
"old_string"
];
exists
{
argsMap
[
"oldString"
]
=
oldString
delete
(
argsMap
,
"old_string"
)
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Renamed 'old_string' to 'oldString' in edit tool"
)
}
}
if
_
,
exists
:=
argsMap
[
"newString"
];
!
exists
{
if
newString
,
exists
:=
argsMap
[
"new_string"
];
exists
{
argsMap
[
"newString"
]
=
newString
delete
(
argsMap
,
"new_string"
)
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Renamed 'new_string' to 'newString' in edit tool"
)
}
}
if
_
,
exists
:=
argsMap
[
"replaceAll"
];
!
exists
{
if
replaceAll
,
exists
:=
argsMap
[
"replace_all"
];
exists
{
argsMap
[
"replaceAll"
]
=
replaceAll
delete
(
argsMap
,
"replace_all"
)
corrected
=
true
log
.
Printf
(
"[CodexToolCorrector] Renamed 'replace_all' to 'replaceAll' in edit tool"
)
}
}
}
}
}
}
...
...
backend/internal/service/openai_tool_corrector_test.go
View file @
bdc426a7
...
@@ -416,22 +416,23 @@ func TestCorrectToolParameters(t *testing.T) {
...
@@ -416,22 +416,23 @@ func TestCorrectToolParameters(t *testing.T) {
expected
map
[
string
]
bool
// key: 期待存在的参数, value: true表示应该存在
expected
map
[
string
]
bool
// key: 期待存在的参数, value: true表示应该存在
}{
}{
{
{
name
:
"re
mov
e workdir
from
bash tool"
,
name
:
"re
nam
e work
_
dir
to workdir in
bash tool"
,
input
:
`{
input
:
`{
"tool_calls": [{
"tool_calls": [{
"function": {
"function": {
"name": "bash",
"name": "bash",
"arguments": "{\"command\":\"ls\",\"workdir\":\"/tmp\"}"
"arguments": "{\"command\":\"ls\",\"work
_
dir\":\"/tmp\"}"
}
}
}]
}]
}`
,
}`
,
expected
:
map
[
string
]
bool
{
expected
:
map
[
string
]
bool
{
"command"
:
true
,
"command"
:
true
,
"workdir"
:
false
,
"workdir"
:
true
,
"work_dir"
:
false
,
},
},
},
},
{
{
name
:
"rename
path to file_path in edit tool
"
,
name
:
"rename
snake_case edit params to camelCase
"
,
input
:
`{
input
:
`{
"tool_calls": [{
"tool_calls": [{
"function": {
"function": {
...
@@ -441,10 +442,12 @@ func TestCorrectToolParameters(t *testing.T) {
...
@@ -441,10 +442,12 @@ func TestCorrectToolParameters(t *testing.T) {
}]
}]
}`
,
}`
,
expected
:
map
[
string
]
bool
{
expected
:
map
[
string
]
bool
{
"file
_p
ath"
:
true
,
"file
P
ath"
:
true
,
"path"
:
false
,
"path"
:
false
,
"old_string"
:
true
,
"oldString"
:
true
,
"new_string"
:
true
,
"old_string"
:
false
,
"newString"
:
true
,
"new_string"
:
false
,
},
},
},
},
}
}
...
...
backend/internal/service/ops_retry.go
View file @
bdc426a7
...
@@ -514,7 +514,7 @@ func (s *OpsService) selectAccountForRetry(ctx context.Context, reqType opsRetry
...
@@ -514,7 +514,7 @@ func (s *OpsService) selectAccountForRetry(ctx context.Context, reqType opsRetry
if
s
.
gatewayService
==
nil
{
if
s
.
gatewayService
==
nil
{
return
nil
,
fmt
.
Errorf
(
"gateway service not available"
)
return
nil
,
fmt
.
Errorf
(
"gateway service not available"
)
}
}
return
s
.
gatewayService
.
SelectAccountWithLoadAwareness
(
ctx
,
groupID
,
""
,
model
,
excludedIDs
)
return
s
.
gatewayService
.
SelectAccountWithLoadAwareness
(
ctx
,
groupID
,
""
,
model
,
excludedIDs
,
""
)
// 重试不使用会话限制
default
:
default
:
return
nil
,
fmt
.
Errorf
(
"unsupported retry type: %s"
,
reqType
)
return
nil
,
fmt
.
Errorf
(
"unsupported retry type: %s"
,
reqType
)
}
}
...
...
backend/internal/service/pricing_service.go
View file @
bdc426a7
...
@@ -531,8 +531,8 @@ func (s *PricingService) buildModelLookupCandidates(modelLower string) []string
...
@@ -531,8 +531,8 @@ func (s *PricingService) buildModelLookupCandidates(modelLower string) []string
func
normalizeModelNameForPricing
(
model
string
)
string
{
func
normalizeModelNameForPricing
(
model
string
)
string
{
// Common Gemini/VertexAI forms:
// Common Gemini/VertexAI forms:
// - models/gemini-2.0-flash-exp
// - models/gemini-2.0-flash-exp
// - publishers/google/models/gemini-
1
.5-pro
// - publishers/google/models/gemini-
2
.5-pro
// - projects/.../locations/.../publishers/google/models/gemini-
1
.5-pro
// - projects/.../locations/.../publishers/google/models/gemini-
2
.5-pro
model
=
strings
.
TrimSpace
(
model
)
model
=
strings
.
TrimSpace
(
model
)
model
=
strings
.
TrimLeft
(
model
,
"/"
)
model
=
strings
.
TrimLeft
(
model
,
"/"
)
model
=
strings
.
TrimPrefix
(
model
,
"models/"
)
model
=
strings
.
TrimPrefix
(
model
,
"models/"
)
...
...
backend/internal/service/session_limit_cache.go
0 → 100644
View file @
bdc426a7
package
service
import
(
"context"
"time"
)
// SessionLimitCache 管理账号级别的活跃会话跟踪
// 用于 Anthropic OAuth/SetupToken 账号的会话数量限制
//
// Key 格式: session_limit:account:{accountID}
// 数据结构: Sorted Set (member=sessionUUID, score=timestamp)
//
// 会话在空闲超时后自动过期,无需手动清理
type
SessionLimitCache
interface
{
// RegisterSession 注册会话活动
// - 如果会话已存在,刷新其时间戳并返回 true
// - 如果会话不存在且活跃会话数 < maxSessions,添加新会话并返回 true
// - 如果会话不存在且活跃会话数 >= maxSessions,返回 false(拒绝)
//
// 参数:
// accountID: 账号 ID
// sessionUUID: 从 metadata.user_id 中提取的会话 UUID
// maxSessions: 最大并发会话数限制
// idleTimeout: 会话空闲超时时间
//
// 返回:
// allowed: true 表示允许(在限制内或会话已存在),false 表示拒绝(超出限制且是新会话)
// error: 操作错误
RegisterSession
(
ctx
context
.
Context
,
accountID
int64
,
sessionUUID
string
,
maxSessions
int
,
idleTimeout
time
.
Duration
)
(
allowed
bool
,
err
error
)
// RefreshSession 刷新现有会话的时间戳
// 用于活跃会话保持活动状态
RefreshSession
(
ctx
context
.
Context
,
accountID
int64
,
sessionUUID
string
,
idleTimeout
time
.
Duration
)
error
// GetActiveSessionCount 获取当前活跃会话数
// 返回未过期的会话数量
GetActiveSessionCount
(
ctx
context
.
Context
,
accountID
int64
)
(
int
,
error
)
// GetActiveSessionCountBatch 批量获取多个账号的活跃会话数
// 返回 map[accountID]count,查询失败的账号不在 map 中
GetActiveSessionCountBatch
(
ctx
context
.
Context
,
accountIDs
[]
int64
)
(
map
[
int64
]
int
,
error
)
// IsSessionActive 检查特定会话是否活跃(未过期)
IsSessionActive
(
ctx
context
.
Context
,
accountID
int64
,
sessionUUID
string
)
(
bool
,
error
)
// ========== 5h窗口费用缓存 ==========
// Key 格式: window_cost:account:{accountID}
// 用于缓存账号在当前5h窗口内的标准费用,减少数据库聚合查询压力
// GetWindowCost 获取缓存的窗口费用
// 返回 (cost, true, nil) 如果缓存命中
// 返回 (0, false, nil) 如果缓存未命中
// 返回 (0, false, err) 如果发生错误
GetWindowCost
(
ctx
context
.
Context
,
accountID
int64
)
(
cost
float64
,
hit
bool
,
err
error
)
// SetWindowCost 设置窗口费用缓存
SetWindowCost
(
ctx
context
.
Context
,
accountID
int64
,
cost
float64
)
error
// GetWindowCostBatch 批量获取窗口费用缓存
// 返回 map[accountID]cost,缓存未命中的账号不在 map 中
GetWindowCostBatch
(
ctx
context
.
Context
,
accountIDs
[]
int64
)
(
map
[
int64
]
float64
,
error
)
}
frontend/src/components/account/AccountCapacityCell.vue
0 → 100644
View file @
bdc426a7
<
template
>
<div
class=
"flex flex-col gap-1.5"
>
<!-- 并发槽位 -->
<div
class=
"flex items-center gap-1.5"
>
<span
:class=
"[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
concurrencyClass
]"
>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/>
</svg>
<span
class=
"font-mono"
>
{{
currentConcurrency
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
account
.
concurrency
}}
</span>
</span>
</div>
<!-- 5h窗口费用限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div
v-if=
"showWindowCost"
class=
"flex items-center gap-1"
>
<span
:class=
"[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
windowCostClass
]"
:title=
"windowCostTooltip"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class=
"font-mono"
>
$
{{
formatCost
(
currentWindowCost
)
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
$
{{
formatCost
(
account
.
window_cost_limit
)
}}
</span>
</span>
</div>
<!-- 会话数量限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div
v-if=
"showSessionLimit"
class=
"flex items-center gap-1"
>
<span
:class=
"[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
sessionLimitClass
]"
:title=
"sessionLimitTooltip"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<span
class=
"font-mono"
>
{{
activeSessions
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
account
.
max_sessions
}}
</span>
</span>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
account
:
Account
}
>
()
const
{
t
}
=
useI18n
()
// 当前并发数
const
currentConcurrency
=
computed
(()
=>
props
.
account
.
current_concurrency
||
0
)
// 是否为 Anthropic OAuth/SetupToken 账号
const
isAnthropicOAuthOrSetupToken
=
computed
(()
=>
{
return
(
props
.
account
.
platform
===
'
anthropic
'
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
)
})
// 是否显示窗口费用限制
const
showWindowCost
=
computed
(()
=>
{
return
(
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
window_cost_limit
!==
undefined
&&
props
.
account
.
window_cost_limit
!==
null
&&
props
.
account
.
window_cost_limit
>
0
)
})
// 当前窗口费用
const
currentWindowCost
=
computed
(()
=>
props
.
account
.
current_window_cost
??
0
)
// 是否显示会话限制
const
showSessionLimit
=
computed
(()
=>
{
return
(
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
max_sessions
!==
undefined
&&
props
.
account
.
max_sessions
!==
null
&&
props
.
account
.
max_sessions
>
0
)
})
// 当前活跃会话数
const
activeSessions
=
computed
(()
=>
props
.
account
.
active_sessions
??
0
)
// 并发状态样式
const
concurrencyClass
=
computed
(()
=>
{
const
current
=
currentConcurrency
.
value
const
max
=
props
.
account
.
concurrency
if
(
current
>=
max
)
{
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
}
if
(
current
>
0
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
return
'
bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400
'
})
// 窗口费用状态样式
const
windowCostClass
=
computed
(()
=>
{
if
(
!
showWindowCost
.
value
)
return
''
const
current
=
currentWindowCost
.
value
const
limit
=
props
.
account
.
window_cost_limit
||
0
const
reserve
=
props
.
account
.
window_cost_sticky_reserve
||
10
// >= 阈值+预留: 完全不可调度 (红色)
if
(
current
>=
limit
+
reserve
)
{
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
}
// >= 阈值: 仅粘性会话 (橙色)
if
(
current
>=
limit
)
{
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
}
// >= 80% 阈值: 警告 (黄色)
if
(
current
>=
limit
*
0.8
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
// 正常 (绿色)
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
})
// 窗口费用提示文字
const
windowCostTooltip
=
computed
(()
=>
{
if
(
!
showWindowCost
.
value
)
return
''
const
current
=
currentWindowCost
.
value
const
limit
=
props
.
account
.
window_cost_limit
||
0
const
reserve
=
props
.
account
.
window_cost_sticky_reserve
||
10
if
(
current
>=
limit
+
reserve
)
{
return
t
(
'
admin.accounts.capacity.windowCost.blocked
'
)
}
if
(
current
>=
limit
)
{
return
t
(
'
admin.accounts.capacity.windowCost.stickyOnly
'
)
}
return
t
(
'
admin.accounts.capacity.windowCost.normal
'
)
})
// 会话限制状态样式
const
sessionLimitClass
=
computed
(()
=>
{
if
(
!
showSessionLimit
.
value
)
return
''
const
current
=
activeSessions
.
value
const
max
=
props
.
account
.
max_sessions
||
0
// >= 最大: 完全占满 (红色)
if
(
current
>=
max
)
{
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
}
// >= 80%: 警告 (黄色)
if
(
current
>=
max
*
0.8
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
// 正常 (绿色)
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
})
// 会话限制提示文字
const
sessionLimitTooltip
=
computed
(()
=>
{
if
(
!
showSessionLimit
.
value
)
return
''
const
current
=
activeSessions
.
value
const
max
=
props
.
account
.
max_sessions
||
0
const
idle
=
props
.
account
.
session_idle_timeout_minutes
||
5
if
(
current
>=
max
)
{
return
t
(
'
admin.accounts.capacity.sessions.full
'
,
{
idle
})
}
return
t
(
'
admin.accounts.capacity.sessions.normal
'
,
{
idle
})
})
// 格式化费用显示
const
formatCost
=
(
value
:
number
|
null
|
undefined
)
=>
{
if
(
value
===
null
||
value
===
undefined
)
return
'
0
'
return
value
.
toFixed
(
2
)
}
</
script
>
frontend/src/components/account/AccountTestModal.vue
View file @
bdc426a7
...
@@ -292,8 +292,11 @@ const loadAvailableModels = async () => {
...
@@ -292,8 +292,11 @@ const loadAvailableModels = async () => {
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.0-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-flash-preview
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro-preview
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
}
else
{
// Try to select Sonnet as default, otherwise use first model
// Try to select Sonnet as default, otherwise use first model
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
bdc426a7
...
@@ -604,6 +604,136 @@
...
@@ -604,6 +604,136 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Quota
Control
Section
(
Anthropic
OAuth
/
SetupToken
only
)
-->
<
div
v
-
if
=
"
account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaControl.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.hint
'
)
}}
<
/p
>
<
/div
>
<!--
Window
Cost
Limit
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
windowCostEnabled = !windowCostEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
windowCostEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limit
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
v
-
model
.
number
=
"
windowCostLimit
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.limitPlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limitHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserve
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
v
-
model
.
number
=
"
windowCostStickyReserve
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserveHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
Session
Limit
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionLimitEnabled = !sessionLimitEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
sessionLimitEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessions
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
maxSessions
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessionsHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeout
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
v
-
model
.
number
=
"
sessionIdleTimeout
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input pr-12
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')
"
/>
<
span
class
=
"
absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
common.minutes
'
)
}}
<
/span
>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeoutHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
common.status
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
common.status
'
)
}}
<
/label
>
...
@@ -767,6 +897,14 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
...
@@ -767,6 +897,14 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
// Quota control state (Anthropic OAuth/SetupToken only)
const
windowCostEnabled
=
ref
(
false
)
const
windowCostLimit
=
ref
<
number
|
null
>
(
null
)
const
windowCostStickyReserve
=
ref
<
number
|
null
>
(
null
)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
// Computed: current preset mappings based on platform
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
props
.
account
?.
platform
||
'
anthropic
'
))
const
presetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
props
.
account
?.
platform
||
'
anthropic
'
))
const
tempUnschedPresets
=
computed
(()
=>
[
const
tempUnschedPresets
=
computed
(()
=>
[
...
@@ -854,6 +992,9 @@ watch(
...
@@ -854,6 +992,9 @@ watch(
const
extra
=
newAccount
.
extra
as
Record
<
string
,
unknown
>
|
undefined
const
extra
=
newAccount
.
extra
as
Record
<
string
,
unknown
>
|
undefined
mixedScheduling
.
value
=
extra
?.
mixed_scheduling
===
true
mixedScheduling
.
value
=
extra
?.
mixed_scheduling
===
true
// Load quota control settings (Anthropic OAuth/SetupToken only)
loadQuotaControlSettings
(
newAccount
)
loadTempUnschedRules
(
credentials
)
loadTempUnschedRules
(
credentials
)
// Initialize API Key fields for apikey type
// Initialize API Key fields for apikey type
...
@@ -1087,6 +1228,35 @@ function loadTempUnschedRules(credentials?: Record<string, unknown>) {
...
@@ -1087,6 +1228,35 @@ function loadTempUnschedRules(credentials?: Record<string, unknown>) {
}
)
}
)
}
}
// Load quota control settings from account (Anthropic OAuth/SetupToken only)
function
loadQuotaControlSettings
(
account
:
Account
)
{
// Reset all quota control state first
windowCostEnabled
.
value
=
false
windowCostLimit
.
value
=
null
windowCostStickyReserve
.
value
=
null
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
// Only applies to Anthropic OAuth/SetupToken accounts
if
(
account
.
platform
!==
'
anthropic
'
||
(
account
.
type
!==
'
oauth
'
&&
account
.
type
!==
'
setup-token
'
))
{
return
}
// Load from extra field (via backend DTO fields)
if
(
account
.
window_cost_limit
!=
null
&&
account
.
window_cost_limit
>
0
)
{
windowCostEnabled
.
value
=
true
windowCostLimit
.
value
=
account
.
window_cost_limit
windowCostStickyReserve
.
value
=
account
.
window_cost_sticky_reserve
??
10
}
if
(
account
.
max_sessions
!=
null
&&
account
.
max_sessions
>
0
)
{
sessionLimitEnabled
.
value
=
true
maxSessions
.
value
=
account
.
max_sessions
sessionIdleTimeout
.
value
=
account
.
session_idle_timeout_minutes
??
5
}
}
function
formatTempUnschedKeywords
(
value
:
unknown
)
{
function
formatTempUnschedKeywords
(
value
:
unknown
)
{
if
(
Array
.
isArray
(
value
))
{
if
(
Array
.
isArray
(
value
))
{
return
value
return
value
...
@@ -1214,6 +1384,32 @@ const handleSubmit = async () => {
...
@@ -1214,6 +1384,32 @@ const handleSubmit = async () => {
updatePayload
.
extra
=
newExtra
updatePayload
.
extra
=
newExtra
}
}
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
if
(
props
.
account
.
platform
===
'
anthropic
'
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
))
{
const
currentExtra
=
(
props
.
account
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
const
newExtra
:
Record
<
string
,
unknown
>
=
{
...
currentExtra
}
// Window cost limit settings
if
(
windowCostEnabled
.
value
&&
windowCostLimit
.
value
!=
null
&&
windowCostLimit
.
value
>
0
)
{
newExtra
.
window_cost_limit
=
windowCostLimit
.
value
newExtra
.
window_cost_sticky_reserve
=
windowCostStickyReserve
.
value
??
10
}
else
{
delete
newExtra
.
window_cost_limit
delete
newExtra
.
window_cost_sticky_reserve
}
// Session limit settings
if
(
sessionLimitEnabled
.
value
&&
maxSessions
.
value
!=
null
&&
maxSessions
.
value
>
0
)
{
newExtra
.
max_sessions
=
maxSessions
.
value
newExtra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
else
{
delete
newExtra
.
max_sessions
delete
newExtra
.
session_idle_timeout_minutes
}
updatePayload
.
extra
=
newExtra
}
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
updatePayload
)
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
updatePayload
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
emit
(
'
updated
'
)
emit
(
'
updated
'
)
...
...
frontend/src/components/admin/account/AccountTableActions.vue
View file @
bdc426a7
<
template
>
<
template
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<slot
name=
"before"
></slot>
<button
@
click=
"$emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary"
>
<button
@
click=
"$emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"[loading ? 'animate-spin' : '']"
/>
<Icon
name=
"refresh"
size=
"md"
:class=
"[loading ? 'animate-spin' : '']"
/>
</button>
</button>
...
...
frontend/src/components/admin/account/AccountTestModal.vue
View file @
bdc426a7
...
@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
...
@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.0-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-flash-preview
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro-preview
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
}
else
{
// Try to select Sonnet as default, otherwise use first model
// Try to select Sonnet as default, otherwise use first model
...
...
frontend/src/components/keys/UseKeyModal.vue
View file @
bdc426a7
...
@@ -443,7 +443,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
...
@@ -443,7 +443,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
}
}
function
generateGeminiCliContent
(
baseUrl
:
string
,
apiKey
:
string
):
FileConfig
{
function
generateGeminiCliContent
(
baseUrl
:
string
,
apiKey
:
string
):
FileConfig
{
const
model
=
'
gemini-2.
5-pro
'
const
model
=
'
gemini-2.
0-flash
'
const
modelComment
=
t
(
'
keys.useKeyModal.gemini.modelComment
'
)
const
modelComment
=
t
(
'
keys.useKeyModal.gemini.modelComment
'
)
let
path
:
string
let
path
:
string
let
content
:
string
let
content
:
string
...
@@ -548,14 +548,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
...
@@ -548,14 +548,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
}
}
const
geminiModels
=
{
const
geminiModels
=
{
'
gemini-3-pro-high
'
:
{
name
:
'
Gemini 3 Pro High
'
},
'
gemini-2.0-flash
'
:
{
name
:
'
Gemini 2.0 Flash
'
},
'
gemini-2.5-flash
'
:
{
name
:
'
Gemini 2.5 Flash
'
},
'
gemini-2.5-pro
'
:
{
name
:
'
Gemini 2.5 Pro
'
},
'
gemini-3-flash-preview
'
:
{
name
:
'
Gemini 3 Flash Preview
'
},
'
gemini-3-pro-preview
'
:
{
name
:
'
Gemini 3 Pro Preview
'
}
}
const
antigravityGeminiModels
=
{
'
gemini-2.5-flash
'
:
{
name
:
'
Gemini 2.5 Flash
'
},
'
gemini-2.5-flash-lite
'
:
{
name
:
'
Gemini 2.5 Flash Lite
'
},
'
gemini-2.5-flash-thinking
'
:
{
name
:
'
Gemini 2.5 Flash Thinking
'
},
'
gemini-3-flash
'
:
{
name
:
'
Gemini 3 Flash
'
},
'
gemini-3-pro-low
'
:
{
name
:
'
Gemini 3 Pro Low
'
},
'
gemini-3-pro-low
'
:
{
name
:
'
Gemini 3 Pro Low
'
},
'
gemini-3-pro-high
'
:
{
name
:
'
Gemini 3 Pro High
'
},
'
gemini-3-pro-preview
'
:
{
name
:
'
Gemini 3 Pro Preview
'
},
'
gemini-3-pro-preview
'
:
{
name
:
'
Gemini 3 Pro Preview
'
},
'
gemini-3-pro-image
'
:
{
name
:
'
Gemini 3 Pro Image
'
},
'
gemini-3-pro-image
'
:
{
name
:
'
Gemini 3 Pro Image
'
}
'
gemini-3-flash
'
:
{
name
:
'
Gemini 3 Flash
'
},
'
gemini-2.5-flash-thinking
'
:
{
name
:
'
Gemini 2.5 Flash Thinking
'
},
'
gemini-2.5-flash
'
:
{
name
:
'
Gemini 2.5 Flash
'
},
'
gemini-2.5-flash-lite
'
:
{
name
:
'
Gemini 2.5 Flash Lite
'
}
}
}
const
claudeModels
=
{
const
claudeModels
=
{
'
claude-opus-4-5-thinking
'
:
{
name
:
'
Claude Opus 4.5 Thinking
'
},
'
claude-opus-4-5-thinking
'
:
{
name
:
'
Claude Opus 4.5 Thinking
'
},
...
@@ -575,7 +583,7 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
...
@@ -575,7 +583,7 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
else
if
(
platform
===
'
antigravity-gemini
'
)
{
}
else
if
(
platform
===
'
antigravity-gemini
'
)
{
provider
[
platform
].
npm
=
'
@ai-sdk/google
'
provider
[
platform
].
npm
=
'
@ai-sdk/google
'
provider
[
platform
].
name
=
'
Antigravity (Gemini)
'
provider
[
platform
].
name
=
'
Antigravity (Gemini)
'
provider
[
platform
].
models
=
g
eminiModels
provider
[
platform
].
models
=
antigravityG
eminiModels
}
else
if
(
platform
===
'
openai
'
)
{
}
else
if
(
platform
===
'
openai
'
)
{
provider
[
platform
].
models
=
openaiModels
provider
[
platform
].
models
=
openaiModels
}
}
...
...
frontend/src/composables/useModelWhitelist.ts
View file @
bdc426a7
...
@@ -43,13 +43,13 @@ export const claudeModels = [
...
@@ -43,13 +43,13 @@ export const claudeModels = [
// Google Gemini
// Google Gemini
const
geminiModels
=
[
const
geminiModels
=
[
'
gemini-2.0-flash
'
,
'
gemini-2.0-flash-lite-preview
'
,
'
gemini-2.0-flash-exp
'
,
// Keep in sync with backend curated Gemini lists.
'
gemini-2.0-pro-exp
'
,
'
gemini-2.0-flash-thinking-exp
'
,
// This list is intentionally conservative (models commonly available across OAuth/API key).
'
gemini-2.
5-pro-exp-03-25
'
,
'
gemini-2.5-pro-preview-03-25
'
,
'
gemini-2.
0-flash
'
,
'
gemini-
3-pro-preview
'
,
'
gemini-
2.5-flash
'
,
'
gemini-
1
.5-pro
'
,
'
gemini-1.5-pro-latest
'
,
'
gemini-
2
.5-pro
'
,
'
gemini-
1.5-flash
'
,
'
gemini-1.5-flash-latest
'
,
'
gemini-1.5-flash-8b
'
,
'
gemini-
3-flash-preview
'
,
'
gemini-
exp-1206
'
'
gemini-
3-pro-preview
'
]
]
// 智谱 GLM
// 智谱 GLM
...
@@ -229,9 +229,8 @@ const openaiPresetMappings = [
...
@@ -229,9 +229,8 @@ const openaiPresetMappings = [
const
geminiPresetMappings
=
[
const
geminiPresetMappings
=
[
{
label
:
'
Flash 2.0
'
,
from
:
'
gemini-2.0-flash
'
,
to
:
'
gemini-2.0-flash
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
},
{
label
:
'
Flash 2.0
'
,
from
:
'
gemini-2.0-flash
'
,
to
:
'
gemini-2.0-flash
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
},
{
label
:
'
Flash Lite
'
,
from
:
'
gemini-2.0-flash-lite-preview
'
,
to
:
'
gemini-2.0-flash-lite-preview
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
},
{
label
:
'
2.5 Flash
'
,
from
:
'
gemini-2.5-flash
'
,
to
:
'
gemini-2.5-flash
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
},
{
label
:
'
1.5 Pro
'
,
from
:
'
gemini-1.5-pro
'
,
to
:
'
gemini-1.5-pro
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
},
{
label
:
'
2.5 Pro
'
,
from
:
'
gemini-2.5-pro
'
,
to
:
'
gemini-2.5-pro
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
{
label
:
'
1.5 Flash
'
,
from
:
'
gemini-1.5-flash
'
,
to
:
'
gemini-1.5-flash
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
]
]
// =====================
// =====================
...
...
frontend/src/i18n/locales/en.ts
View file @
bdc426a7
...
@@ -163,6 +163,7 @@ export default {
...
@@ -163,6 +163,7 @@ export default {
notAvailable
:
'
N/A
'
,
notAvailable
:
'
N/A
'
,
now
:
'
Now
'
,
now
:
'
Now
'
,
unknown
:
'
Unknown
'
,
unknown
:
'
Unknown
'
,
minutes
:
'
min
'
,
time
:
{
time
:
{
never
:
'
Never
'
,
never
:
'
Never
'
,
justNow
:
'
Just now
'
,
justNow
:
'
Just now
'
,
...
@@ -675,6 +676,7 @@ export default {
...
@@ -675,6 +676,7 @@ export default {
updating
:
'
Updating...
'
,
updating
:
'
Updating...
'
,
columns
:
{
columns
:
{
user
:
'
User
'
,
user
:
'
User
'
,
email
:
'
Email
'
,
username
:
'
Username
'
,
username
:
'
Username
'
,
notes
:
'
Notes
'
,
notes
:
'
Notes
'
,
role
:
'
Role
'
,
role
:
'
Role
'
,
...
@@ -1085,7 +1087,7 @@ export default {
...
@@ -1085,7 +1087,7 @@ export default {
platformType
:
'
Platform/Type
'
,
platformType
:
'
Platform/Type
'
,
platform
:
'
Platform
'
,
platform
:
'
Platform
'
,
type
:
'
Type
'
,
type
:
'
Type
'
,
c
oncurrencyStatus
:
'
Concurrenc
y
'
,
c
apacity
:
'
Capacit
y
'
,
notes
:
'
Notes
'
,
notes
:
'
Notes
'
,
priority
:
'
Priority
'
,
priority
:
'
Priority
'
,
billingRateMultiplier
:
'
Billing Rate
'
,
billingRateMultiplier
:
'
Billing Rate
'
,
...
@@ -1095,10 +1097,23 @@ export default {
...
@@ -1095,10 +1097,23 @@ export default {
todayStats
:
'
Today Stats
'
,
todayStats
:
'
Today Stats
'
,
groups
:
'
Groups
'
,
groups
:
'
Groups
'
,
usageWindows
:
'
Usage Windows
'
,
usageWindows
:
'
Usage Windows
'
,
proxy
:
'
Proxy
'
,
lastUsed
:
'
Last Used
'
,
lastUsed
:
'
Last Used
'
,
expiresAt
:
'
Expires At
'
,
expiresAt
:
'
Expires At
'
,
actions
:
'
Actions
'
actions
:
'
Actions
'
},
},
// Capacity status tooltips
capacity
:
{
windowCost
:
{
blocked
:
'
5h window cost exceeded, account scheduling paused
'
,
stickyOnly
:
'
5h window cost at threshold, only sticky sessions allowed
'
,
normal
:
'
5h window cost normal
'
},
sessions
:
{
full
:
'
Active sessions full, new sessions must wait (idle timeout: {idle} min)
'
,
normal
:
'
Active sessions normal (idle timeout: {idle} min)
'
}
},
tempUnschedulable
:
{
tempUnschedulable
:
{
title
:
'
Temp Unschedulable
'
,
title
:
'
Temp Unschedulable
'
,
statusTitle
:
'
Temp Unschedulable Status
'
,
statusTitle
:
'
Temp Unschedulable Status
'
,
...
@@ -1250,6 +1265,31 @@ export default {
...
@@ -1250,6 +1265,31 @@ export default {
'
When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens
'
,
'
When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens
'
,
autoPauseOnExpired
:
'
Auto Pause On Expired
'
,
autoPauseOnExpired
:
'
Auto Pause On Expired
'
,
autoPauseOnExpiredDesc
:
'
When enabled, the account will auto pause scheduling after it expires
'
,
autoPauseOnExpiredDesc
:
'
When enabled, the account will auto pause scheduling after it expires
'
,
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl
:
{
title
:
'
Quota Control
'
,
hint
:
'
Only applies to Anthropic OAuth/Setup Token accounts
'
,
windowCost
:
{
label
:
'
5h Window Cost Limit
'
,
hint
:
'
Limit account cost usage within the 5-hour window
'
,
limit
:
'
Cost Threshold
'
,
limitPlaceholder
:
'
50
'
,
limitHint
:
'
Account will not participate in new scheduling after reaching threshold
'
,
stickyReserve
:
'
Sticky Reserve
'
,
stickyReservePlaceholder
:
'
10
'
,
stickyReserveHint
:
'
Additional reserve for sticky sessions
'
},
sessionLimit
:
{
label
:
'
Session Count Limit
'
,
hint
:
'
Limit the number of active concurrent sessions
'
,
maxSessions
:
'
Max Sessions
'
,
maxSessionsPlaceholder
:
'
3
'
,
maxSessionsHint
:
'
Maximum number of active concurrent sessions
'
,
idleTimeout
:
'
Idle Timeout
'
,
idleTimeoutPlaceholder
:
'
5
'
,
idleTimeoutHint
:
'
Sessions will be released after idle timeout
'
}
},
expired
:
'
Expired
'
,
expired
:
'
Expired
'
,
proxy
:
'
Proxy
'
,
proxy
:
'
Proxy
'
,
noProxy
:
'
No Proxy
'
,
noProxy
:
'
No Proxy
'
,
...
...
Prev
1
2
3
Next
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