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
0debe0a8
Commit
0debe0a8
authored
Mar 07, 2026
by
神乐
Browse files
fix: 修复 OpenAI WS 用量窗口刷新与限额纠偏
parent
bcb6444f
Changes
12
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/account_test_service.go
View file @
0debe0a8
...
...
@@ -408,6 +408,16 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
if
resp
.
StatusCode
!=
http
.
StatusOK
{
body
,
_
:=
io
.
ReadAll
(
resp
.
Body
)
if
isOAuth
&&
s
.
accountRepo
!=
nil
{
if
updates
,
err
:=
extractOpenAICodexProbeUpdates
(
resp
);
err
==
nil
&&
len
(
updates
)
>
0
{
_
=
s
.
accountRepo
.
UpdateExtra
(
ctx
,
account
.
ID
,
updates
)
mergeAccountExtra
(
account
,
updates
)
}
if
resetAt
:=
(
&
RateLimitService
{})
.
calculateOpenAI429ResetTime
(
resp
.
Header
);
resetAt
!=
nil
{
_
=
s
.
accountRepo
.
SetRateLimited
(
ctx
,
account
.
ID
,
*
resetAt
)
account
.
RateLimitResetAt
=
resetAt
}
}
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"API returned %d: %s"
,
resp
.
StatusCode
,
string
(
body
)))
}
...
...
backend/internal/service/account_test_service_openai_test.go
0 → 100644
View file @
0debe0a8
//go:build unit
package
service
import
(
"context"
"net/http"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
openAIAccountTestRepo
struct
{
mockAccountRepoForGemini
updatedExtra
map
[
string
]
any
rateLimitedID
int64
rateLimitedAt
*
time
.
Time
}
func
(
r
*
openAIAccountTestRepo
)
UpdateExtra
(
_
context
.
Context
,
_
int64
,
updates
map
[
string
]
any
)
error
{
r
.
updatedExtra
=
updates
return
nil
}
func
(
r
*
openAIAccountTestRepo
)
SetRateLimited
(
_
context
.
Context
,
id
int64
,
resetAt
time
.
Time
)
error
{
r
.
rateLimitedID
=
id
r
.
rateLimitedAt
=
&
resetAt
return
nil
}
func
TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimit
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
ctx
,
_
:=
newSoraTestContext
()
resp
:=
newJSONResponse
(
http
.
StatusTooManyRequests
,
`{"error":{"type":"usage_limit_reached","message":"limit reached"}}`
)
resp
.
Header
.
Set
(
"x-codex-primary-used-percent"
,
"100"
)
resp
.
Header
.
Set
(
"x-codex-primary-reset-after-seconds"
,
"604800"
)
resp
.
Header
.
Set
(
"x-codex-primary-window-minutes"
,
"10080"
)
resp
.
Header
.
Set
(
"x-codex-secondary-used-percent"
,
"100"
)
resp
.
Header
.
Set
(
"x-codex-secondary-reset-after-seconds"
,
"18000"
)
resp
.
Header
.
Set
(
"x-codex-secondary-window-minutes"
,
"300"
)
repo
:=
&
openAIAccountTestRepo
{}
upstream
:=
&
queuedHTTPUpstream
{
responses
:
[]
*
http
.
Response
{
resp
}}
svc
:=
&
AccountTestService
{
accountRepo
:
repo
,
httpUpstream
:
upstream
}
account
:=
&
Account
{
ID
:
88
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"access_token"
:
"test-token"
},
}
err
:=
svc
.
testOpenAIAccountConnection
(
ctx
,
account
,
"gpt-5.4"
)
require
.
Error
(
t
,
err
)
require
.
NotEmpty
(
t
,
repo
.
updatedExtra
)
require
.
Equal
(
t
,
100.0
,
repo
.
updatedExtra
[
"codex_5h_used_percent"
])
require
.
Equal
(
t
,
int64
(
88
),
repo
.
rateLimitedID
)
require
.
NotNil
(
t
,
repo
.
rateLimitedAt
)
require
.
NotNil
(
t
,
account
.
RateLimitResetAt
)
if
account
.
RateLimitResetAt
!=
nil
&&
repo
.
rateLimitedAt
!=
nil
{
require
.
WithinDuration
(
t
,
*
repo
.
rateLimitedAt
,
*
account
.
RateLimitResetAt
,
time
.
Second
)
}
}
backend/internal/service/account_usage_service.go
View file @
0debe0a8
...
...
@@ -367,7 +367,7 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
usage
.
SevenDay
=
progress
}
if
(
usage
.
FiveHour
==
nil
||
usage
.
SevenDay
==
nil
)
&&
s
.
shouldProbeOpenAICodexSnapshot
(
account
.
ID
,
now
)
{
if
shouldRefreshOpenAICodexSnapshot
(
account
,
usage
,
now
)
&&
s
.
shouldProbeOpenAICodexSnapshot
(
account
.
ID
,
now
)
{
if
updates
,
err
:=
s
.
probeOpenAICodexSnapshot
(
ctx
,
account
);
err
==
nil
&&
len
(
updates
)
>
0
{
mergeAccountExtra
(
account
,
updates
)
if
usage
.
UpdatedAt
==
nil
{
...
...
@@ -409,6 +409,40 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
return
usage
,
nil
}
func
shouldRefreshOpenAICodexSnapshot
(
account
*
Account
,
usage
*
UsageInfo
,
now
time
.
Time
)
bool
{
if
account
==
nil
{
return
false
}
if
usage
==
nil
{
return
true
}
if
usage
.
FiveHour
==
nil
||
usage
.
SevenDay
==
nil
{
return
true
}
if
account
.
IsRateLimited
()
{
return
true
}
return
isOpenAICodexSnapshotStale
(
account
,
now
)
}
func
isOpenAICodexSnapshotStale
(
account
*
Account
,
now
time
.
Time
)
bool
{
if
account
==
nil
||
!
account
.
IsOpenAIOAuth
()
||
!
account
.
IsOpenAIResponsesWebSocketV2Enabled
()
{
return
false
}
if
account
.
Extra
==
nil
{
return
true
}
raw
,
ok
:=
account
.
Extra
[
"codex_usage_updated_at"
]
if
!
ok
{
return
true
}
ts
,
err
:=
parseTime
(
fmt
.
Sprint
(
raw
))
if
err
!=
nil
{
return
true
}
return
now
.
Sub
(
ts
)
>=
openAIProbeCacheTTL
}
func
(
s
*
AccountUsageService
)
shouldProbeOpenAICodexSnapshot
(
accountID
int64
,
now
time
.
Time
)
bool
{
if
s
==
nil
||
s
.
cache
==
nil
||
accountID
<=
0
{
return
true
...
...
@@ -478,20 +512,34 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
<
200
||
resp
.
StatusCode
>=
300
{
return
nil
,
fmt
.
Errorf
(
"openai codex probe returned status %d"
,
resp
.
StatusCode
)
updates
,
err
:=
extractOpenAICodexProbeUpdates
(
resp
)
if
err
!=
nil
{
return
nil
,
err
}
if
len
(
updates
)
>
0
{
go
func
(
accountID
int64
,
updates
map
[
string
]
any
)
{
updateCtx
,
updateCancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
updateCancel
()
_
=
s
.
accountRepo
.
UpdateExtra
(
updateCtx
,
accountID
,
updates
)
}(
account
.
ID
,
updates
)
return
updates
,
nil
}
return
nil
,
nil
}
func
extractOpenAICodexProbeUpdates
(
resp
*
http
.
Response
)
(
map
[
string
]
any
,
error
)
{
if
resp
==
nil
{
return
nil
,
nil
}
if
snapshot
:=
ParseCodexRateLimitHeaders
(
resp
.
Header
);
snapshot
!=
nil
{
updates
:=
buildCodexUsageExtraUpdates
(
snapshot
,
time
.
Now
())
if
len
(
updates
)
>
0
{
go
func
(
accountID
int64
,
updates
map
[
string
]
any
)
{
updateCtx
,
updateCancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
updateCancel
()
_
=
s
.
accountRepo
.
UpdateExtra
(
updateCtx
,
accountID
,
updates
)
}(
account
.
ID
,
updates
)
return
updates
,
nil
}
}
if
resp
.
StatusCode
<
200
||
resp
.
StatusCode
>=
300
{
return
nil
,
fmt
.
Errorf
(
"openai codex probe returned status %d"
,
resp
.
StatusCode
)
}
return
nil
,
nil
}
...
...
backend/internal/service/account_usage_service_test.go
0 → 100644
View file @
0debe0a8
package
service
import
(
"net/http"
"testing"
"time"
)
func
TestShouldRefreshOpenAICodexSnapshot
(
t
*
testing
.
T
)
{
t
.
Parallel
()
rateLimitedUntil
:=
time
.
Now
()
.
Add
(
5
*
time
.
Minute
)
now
:=
time
.
Now
()
usage
:=
&
UsageInfo
{
FiveHour
:
&
UsageProgress
{
Utilization
:
0
},
SevenDay
:
&
UsageProgress
{
Utilization
:
0
},
}
if
!
shouldRefreshOpenAICodexSnapshot
(
&
Account
{
RateLimitResetAt
:
&
rateLimitedUntil
},
usage
,
now
)
{
t
.
Fatal
(
"expected rate-limited account to force codex snapshot refresh"
)
}
if
shouldRefreshOpenAICodexSnapshot
(
&
Account
{},
usage
,
now
)
{
t
.
Fatal
(
"expected complete non-rate-limited usage to skip codex snapshot refresh"
)
}
if
!
shouldRefreshOpenAICodexSnapshot
(
&
Account
{},
&
UsageInfo
{
FiveHour
:
nil
,
SevenDay
:
&
UsageProgress
{}},
now
)
{
t
.
Fatal
(
"expected missing 5h snapshot to require refresh"
)
}
staleAt
:=
now
.
Add
(
-
(
openAIProbeCacheTTL
+
time
.
Minute
))
.
Format
(
time
.
RFC3339
)
if
!
shouldRefreshOpenAICodexSnapshot
(
&
Account
{
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
Extra
:
map
[
string
]
any
{
"openai_oauth_responses_websockets_v2_enabled"
:
true
,
"codex_usage_updated_at"
:
staleAt
,
},
},
usage
,
now
)
{
t
.
Fatal
(
"expected stale ws snapshot to trigger refresh"
)
}
}
func
TestExtractOpenAICodexProbeUpdatesAccepts429WithCodexHeaders
(
t
*
testing
.
T
)
{
t
.
Parallel
()
headers
:=
make
(
http
.
Header
)
headers
.
Set
(
"x-codex-primary-used-percent"
,
"100"
)
headers
.
Set
(
"x-codex-primary-reset-after-seconds"
,
"604800"
)
headers
.
Set
(
"x-codex-primary-window-minutes"
,
"10080"
)
headers
.
Set
(
"x-codex-secondary-used-percent"
,
"100"
)
headers
.
Set
(
"x-codex-secondary-reset-after-seconds"
,
"18000"
)
headers
.
Set
(
"x-codex-secondary-window-minutes"
,
"300"
)
updates
,
err
:=
extractOpenAICodexProbeUpdates
(
&
http
.
Response
{
StatusCode
:
http
.
StatusTooManyRequests
,
Header
:
headers
})
if
err
!=
nil
{
t
.
Fatalf
(
"extractOpenAICodexProbeUpdates() error = %v"
,
err
)
}
if
len
(
updates
)
==
0
{
t
.
Fatal
(
"expected codex probe updates from 429 headers"
)
}
if
got
:=
updates
[
"codex_5h_used_percent"
];
got
!=
100.0
{
t
.
Fatalf
(
"codex_5h_used_percent = %v, want 100"
,
got
)
}
if
got
:=
updates
[
"codex_7d_used_percent"
];
got
!=
100.0
{
t
.
Fatalf
(
"codex_7d_used_percent = %v, want 100"
,
got
)
}
}
backend/internal/service/ratelimit_service.go
View file @
0debe0a8
...
...
@@ -615,6 +615,7 @@ func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *A
func
(
s
*
RateLimitService
)
handle429
(
ctx
context
.
Context
,
account
*
Account
,
headers
http
.
Header
,
responseBody
[]
byte
)
{
// 1. OpenAI 平台:优先尝试解析 x-codex-* 响应头(用于 rate_limit_exceeded)
if
account
.
Platform
==
PlatformOpenAI
{
s
.
persistOpenAICodexSnapshot
(
ctx
,
account
,
headers
)
if
resetAt
:=
s
.
calculateOpenAI429ResetTime
(
headers
);
resetAt
!=
nil
{
if
err
:=
s
.
accountRepo
.
SetRateLimited
(
ctx
,
account
.
ID
,
*
resetAt
);
err
!=
nil
{
slog
.
Warn
(
"rate_limit_set_failed"
,
"account_id"
,
account
.
ID
,
"error"
,
err
)
...
...
@@ -878,6 +879,23 @@ func pickSooner(a, b *time.Time) *time.Time {
}
}
func
(
s
*
RateLimitService
)
persistOpenAICodexSnapshot
(
ctx
context
.
Context
,
account
*
Account
,
headers
http
.
Header
)
{
if
s
==
nil
||
s
.
accountRepo
==
nil
||
account
==
nil
||
headers
==
nil
{
return
}
snapshot
:=
ParseCodexRateLimitHeaders
(
headers
)
if
snapshot
==
nil
{
return
}
updates
:=
buildCodexUsageExtraUpdates
(
snapshot
,
time
.
Now
())
if
len
(
updates
)
==
0
{
return
}
if
err
:=
s
.
accountRepo
.
UpdateExtra
(
ctx
,
account
.
ID
,
updates
);
err
!=
nil
{
slog
.
Warn
(
"openai_codex_snapshot_persist_failed"
,
"account_id"
,
account
.
ID
,
"error"
,
err
)
}
}
// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
// OpenAI 的 usage_limit_reached 错误格式:
//
...
...
backend/internal/service/ratelimit_service_openai_test.go
View file @
0debe0a8
package
service
import
(
"context"
"net/http"
"testing"
"time"
...
...
@@ -141,6 +142,51 @@ func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) {
}
}
type
openAI429SnapshotRepo
struct
{
mockAccountRepoForGemini
rateLimitedID
int64
updatedExtra
map
[
string
]
any
}
func
(
r
*
openAI429SnapshotRepo
)
SetRateLimited
(
_
context
.
Context
,
id
int64
,
_
time
.
Time
)
error
{
r
.
rateLimitedID
=
id
return
nil
}
func
(
r
*
openAI429SnapshotRepo
)
UpdateExtra
(
_
context
.
Context
,
_
int64
,
updates
map
[
string
]
any
)
error
{
r
.
updatedExtra
=
updates
return
nil
}
func
TestHandle429_OpenAIPersistsCodexSnapshotImmediately
(
t
*
testing
.
T
)
{
repo
:=
&
openAI429SnapshotRepo
{}
svc
:=
NewRateLimitService
(
repo
,
nil
,
nil
,
nil
,
nil
)
account
:=
&
Account
{
ID
:
123
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
}
headers
:=
http
.
Header
{}
headers
.
Set
(
"x-codex-primary-used-percent"
,
"100"
)
headers
.
Set
(
"x-codex-primary-reset-after-seconds"
,
"604800"
)
headers
.
Set
(
"x-codex-primary-window-minutes"
,
"10080"
)
headers
.
Set
(
"x-codex-secondary-used-percent"
,
"100"
)
headers
.
Set
(
"x-codex-secondary-reset-after-seconds"
,
"18000"
)
headers
.
Set
(
"x-codex-secondary-window-minutes"
,
"300"
)
svc
.
handle429
(
context
.
Background
(),
account
,
headers
,
nil
)
if
repo
.
rateLimitedID
!=
account
.
ID
{
t
.
Fatalf
(
"rateLimitedID = %d, want %d"
,
repo
.
rateLimitedID
,
account
.
ID
)
}
if
len
(
repo
.
updatedExtra
)
==
0
{
t
.
Fatal
(
"expected codex snapshot to be persisted on 429"
)
}
if
got
:=
repo
.
updatedExtra
[
"codex_5h_used_percent"
];
got
!=
100.0
{
t
.
Fatalf
(
"codex_5h_used_percent = %v, want 100"
,
got
)
}
if
got
:=
repo
.
updatedExtra
[
"codex_7d_used_percent"
];
got
!=
100.0
{
t
.
Fatalf
(
"codex_7d_used_percent = %v, want 100"
,
got
)
}
}
func
TestNormalizedCodexLimits
(
t
*
testing
.
T
)
{
// Test the Normalize() method directly
pUsed
:=
100.0
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
0debe0a8
...
...
@@ -69,9 +69,39 @@
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
</template>
<!-- OpenAI OAuth accounts:
show Codex usage from extra field
-->
<!-- OpenAI OAuth accounts:
prefer fresh usage query for active rate-limited rows
-->
<
template
v-else-if=
"account.platform === 'openai' && account.type === 'oauth'"
>
<div
v-if=
"hasCodexUsage"
class=
"space-y-1"
>
<div
v-if=
"preferFetchedOpenAIUsage"
class=
"space-y-1"
>
<UsageProgressBar
v-if=
"usageInfo?.five_hour"
label=
"5h"
:utilization=
"usageInfo.five_hour.utilization"
:resets-at=
"usageInfo.five_hour.resets_at"
:window-stats=
"usageInfo.five_hour.window_stats"
color=
"indigo"
/>
<UsageProgressBar
v-if=
"usageInfo?.seven_day"
label=
"7d"
:utilization=
"usageInfo.seven_day.utilization"
:resets-at=
"usageInfo.seven_day.resets_at"
:window-stats=
"usageInfo.seven_day.window_stats"
color=
"emerald"
/>
</div>
<div
v-else-if=
"isActiveOpenAIRateLimited && loading"
class=
"space-y-1.5"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
</div>
<div
class=
"flex items-center gap-1"
>
<div
class=
"h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
</div>
</div>
<div
v-else-if=
"hasCodexUsage"
class=
"space-y-1"
>
<!-- 5h Window -->
<UsageProgressBar
v-if=
"codex5hUsedPercent !== null"
...
...
@@ -308,10 +338,11 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
AccountUsageInfo
,
GeminiCredentials
,
WindowStats
}
from
'
@/types
'
import
{
buildOpenAIUsageRefreshKey
}
from
'
@/utils/accountUsageRefresh
'
import
{
resolveCodexUsageWindow
}
from
'
@/utils/codexUsage
'
import
UsageProgressBar
from
'
./UsageProgressBar.vue
'
import
AccountQuotaInfo
from
'
./AccountQuotaInfo.vue
'
...
...
@@ -373,6 +404,26 @@ const hasOpenAIUsageFallback = computed(() => {
return
!!
usageInfo
.
value
?.
five_hour
||
!!
usageInfo
.
value
?.
seven_day
})
const
isActiveOpenAIRateLimited
=
computed
(()
=>
{
if
(
props
.
account
.
platform
!==
'
openai
'
||
props
.
account
.
type
!==
'
oauth
'
)
return
false
if
(
!
props
.
account
.
rate_limit_reset_at
)
return
false
const
resetAt
=
Date
.
parse
(
props
.
account
.
rate_limit_reset_at
)
return
!
Number
.
isNaN
(
resetAt
)
&&
resetAt
>
Date
.
now
()
})
const
preferFetchedOpenAIUsage
=
computed
(()
=>
{
return
isActiveOpenAIRateLimited
.
value
&&
hasOpenAIUsageFallback
.
value
})
const
openAIUsageRefreshKey
=
computed
(()
=>
buildOpenAIUsageRefreshKey
(
props
.
account
))
const
shouldAutoLoadUsageOnMount
=
computed
(()
=>
{
if
(
props
.
account
.
platform
===
'
openai
'
&&
props
.
account
.
type
===
'
oauth
'
)
{
return
isActiveOpenAIRateLimited
.
value
||
!
hasCodexUsage
.
value
}
return
shouldFetchUsage
.
value
})
const
codex5hUsedPercent
=
computed
(()
=>
codex5hWindow
.
value
.
usedPercent
)
const
codex5hResetAt
=
computed
(()
=>
codex5hWindow
.
value
.
resetAt
)
const
codex7dUsedPercent
=
computed
(()
=>
codex7dWindow
.
value
.
usedPercent
)
...
...
@@ -749,6 +800,17 @@ const loadUsage = async () => {
}
onMounted
(()
=>
{
if
(
!
shouldAutoLoadUsageOnMount
.
value
)
return
loadUsage
()
})
watch
(
openAIUsageRefreshKey
,
(
nextKey
,
prevKey
)
=>
{
if
(
!
prevKey
||
nextKey
===
prevKey
)
return
if
(
props
.
account
.
platform
!==
'
openai
'
||
props
.
account
.
type
!==
'
oauth
'
)
return
if
(
!
isActiveOpenAIRateLimited
.
value
&&
hasCodexUsage
.
value
)
return
loadUsage
().
catch
((
e
)
=>
{
console
.
error
(
'
Failed to refresh OpenAI usage:
'
,
e
)
})
})
</
script
>
frontend/src/components/account/UsageProgressBar.vue
View file @
0debe0a8
<
template
>
<div>
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
<div
v-if=
"windowStats"
class=
"mb-0.5 flex items-center justify-between"
:title=
"statsTitle || t('admin.accounts.usageWindow.statsTitle')"
>
<div
class=
"flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
{{
formatRequests
}}
req
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
{{
formatTokens
}}
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
A $
{{
formatAccountCost
}}
</span>
<span
v-if=
"windowStats?.user_cost != null"
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U $
{{
formatUserCost
}}
</span>
</div>
</div>
<!-- Progress bar row -->
<div
class=
"flex items-center gap-1"
>
<!-- Label badge (fixed width for alignment) -->
...
...
@@ -57,7 +32,6 @@
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
WindowStats
}
from
'
@/types
'
const
props
=
defineProps
<
{
...
...
@@ -66,11 +40,8 @@ const props = defineProps<{
resetsAt
?:
string
|
null
color
:
'
indigo
'
|
'
emerald
'
|
'
purple
'
|
'
amber
'
windowStats
?:
WindowStats
|
null
statsTitle
?:
string
}
>
()
const
{
t
}
=
useI18n
()
// Label background colors
const
labelClass
=
computed
(()
=>
{
const
colors
=
{
...
...
@@ -117,12 +88,12 @@ const displayPercent = computed(() => {
// Format reset time
const
formatResetTime
=
computed
(()
=>
{
if
(
!
props
.
resetsAt
)
return
t
(
'
common.notAvailable
'
)
if
(
!
props
.
resetsAt
)
return
'
-
'
const
date
=
new
Date
(
props
.
resetsAt
)
const
now
=
new
Date
()
const
diffMs
=
date
.
getTime
()
-
now
.
getTime
()
if
(
diffMs
<=
0
)
return
t
(
'
common.now
'
)
if
(
diffMs
<=
0
)
return
'
现在
'
const
diffHours
=
Math
.
floor
(
diffMs
/
(
1000
*
60
*
60
))
const
diffMins
=
Math
.
floor
((
diffMs
%
(
1000
*
60
*
60
))
/
(
1000
*
60
))
...
...
@@ -137,31 +108,4 @@ const formatResetTime = computed(() => {
}
})
// Format window stats
const
formatRequests
=
computed
(()
=>
{
if
(
!
props
.
windowStats
)
return
''
const
r
=
props
.
windowStats
.
requests
if
(
r
>=
1000000
)
return
`
${(
r
/
1000000
).
toFixed
(
1
)}
M`
if
(
r
>=
1000
)
return
`
${(
r
/
1000
).
toFixed
(
1
)}
K`
return
r
.
toString
()
})
const
formatTokens
=
computed
(()
=>
{
if
(
!
props
.
windowStats
)
return
''
const
t
=
props
.
windowStats
.
tokens
if
(
t
>=
1000000000
)
return
`
${(
t
/
1000000000
).
toFixed
(
1
)}
B`
if
(
t
>=
1000000
)
return
`
${(
t
/
1000000
).
toFixed
(
1
)}
M`
if
(
t
>=
1000
)
return
`
${(
t
/
1000
).
toFixed
(
1
)}
K`
return
t
.
toString
()
})
const
formatAccountCost
=
computed
(()
=>
{
if
(
!
props
.
windowStats
)
return
'
0.00
'
return
props
.
windowStats
.
cost
.
toFixed
(
2
)
})
const
formatUserCost
=
computed
(()
=>
{
if
(
!
props
.
windowStats
||
props
.
windowStats
.
user_cost
==
null
)
return
'
0.00
'
return
props
.
windowStats
.
user_cost
.
toFixed
(
2
)
})
</
script
>
frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
View file @
0debe0a8
...
...
@@ -68,6 +68,40 @@ describe('AccountUsageCell', () => {
expect
(
wrapper
.
text
()).
toContain
(
'
admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z
'
)
})
it
(
'
OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage
'
,
async
()
=>
{
const
wrapper
=
mount
(
AccountUsageCell
,
{
props
:
{
account
:
{
id
:
2001
,
platform
:
'
openai
'
,
type
:
'
oauth
'
,
extra
:
{
codex_5h_used_percent
:
12
,
codex_5h_reset_at
:
'
2099-03-07T12:00:00Z
'
,
codex_7d_used_percent
:
34
,
codex_7d_reset_at
:
'
2099-03-13T12:00:00Z
'
}
}
as
any
},
global
:
{
stubs
:
{
UsageProgressBar
:
{
props
:
[
'
label
'
,
'
utilization
'
,
'
resetsAt
'
,
'
windowStats
'
,
'
color
'
],
template
:
'
<div class="usage-bar">{{ label }}|{{ utilization }}</div>
'
},
AccountQuotaInfo
:
true
}
}
})
await
flushPromises
()
expect
(
getUsage
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
5h|12
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
7d|34
'
)
})
it
(
'
OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口
'
,
async
()
=>
{
getUsage
.
mockResolvedValue
({
five_hour
:
{
...
...
@@ -122,4 +156,137 @@ describe('AccountUsageCell', () => {
expect
(
wrapper
.
text
()).
toContain
(
'
5h|0|27700
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
7d|0|27700
'
)
})
it
(
'
OpenAI OAuth 在行数据刷新但仍无 codex 快照时会重新拉取 usage
'
,
async
()
=>
{
getUsage
.
mockResolvedValueOnce
({
five_hour
:
{
utilization
:
0
,
resets_at
:
null
,
remaining_seconds
:
0
,
window_stats
:
{
requests
:
1
,
tokens
:
100
,
cost
:
0.01
,
standard_cost
:
0.01
,
user_cost
:
0.01
}
},
seven_day
:
null
})
.
mockResolvedValueOnce
({
five_hour
:
{
utilization
:
0
,
resets_at
:
null
,
remaining_seconds
:
0
,
window_stats
:
{
requests
:
2
,
tokens
:
200
,
cost
:
0.02
,
standard_cost
:
0.02
,
user_cost
:
0.02
}
},
seven_day
:
null
})
const
wrapper
=
mount
(
AccountUsageCell
,
{
props
:
{
account
:
{
id
:
2003
,
platform
:
'
openai
'
,
type
:
'
oauth
'
,
updated_at
:
'
2026-03-07T10:00:00Z
'
,
extra
:
{}
}
as
any
},
global
:
{
stubs
:
{
UsageProgressBar
:
{
props
:
[
'
label
'
,
'
utilization
'
,
'
resetsAt
'
,
'
windowStats
'
,
'
color
'
],
template
:
'
<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>
'
},
AccountQuotaInfo
:
true
}
}
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
5h|0|100
'
)
expect
(
getUsage
).
toHaveBeenCalledTimes
(
1
)
await
wrapper
.
setProps
({
account
:
{
id
:
2003
,
platform
:
'
openai
'
,
type
:
'
oauth
'
,
updated_at
:
'
2026-03-07T10:01:00Z
'
,
extra
:
{}
}
})
await
flushPromises
()
expect
(
getUsage
).
toHaveBeenCalledTimes
(
2
)
expect
(
wrapper
.
text
()).
toContain
(
'
5h|0|200
'
)
})
it
(
'
OpenAI OAuth 已限额时首屏优先展示重新查询后的 usage,而不是旧 codex 快照
'
,
async
()
=>
{
getUsage
.
mockResolvedValue
({
five_hour
:
{
utilization
:
100
,
resets_at
:
'
2026-03-07T12:00:00Z
'
,
remaining_seconds
:
3600
,
window_stats
:
{
requests
:
211
,
tokens
:
106540000
,
cost
:
38.13
,
standard_cost
:
38.13
,
user_cost
:
38.13
}
},
seven_day
:
{
utilization
:
100
,
resets_at
:
'
2026-03-13T12:00:00Z
'
,
remaining_seconds
:
3600
,
window_stats
:
{
requests
:
211
,
tokens
:
106540000
,
cost
:
38.13
,
standard_cost
:
38.13
,
user_cost
:
38.13
}
}
})
const
wrapper
=
mount
(
AccountUsageCell
,
{
props
:
{
account
:
{
id
:
2004
,
platform
:
'
openai
'
,
type
:
'
oauth
'
,
rate_limit_reset_at
:
'
2099-03-07T12:00:00Z
'
,
extra
:
{
codex_5h_used_percent
:
0
,
codex_7d_used_percent
:
0
}
}
as
any
},
global
:
{
stubs
:
{
UsageProgressBar
:
{
props
:
[
'
label
'
,
'
utilization
'
,
'
resetsAt
'
,
'
windowStats
'
,
'
color
'
],
template
:
'
<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>
'
},
AccountQuotaInfo
:
true
}
}
})
await
flushPromises
()
expect
(
getUsage
).
toHaveBeenCalledWith
(
2004
)
expect
(
wrapper
.
text
()).
toContain
(
'
5h|100|106540000
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
7d|100|106540000
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
5h|0|
'
)
})
})
frontend/src/utils/__tests__/accountUsageRefresh.spec.ts
0 → 100644
View file @
0debe0a8
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
buildOpenAIUsageRefreshKey
}
from
'
../accountUsageRefresh
'
describe
(
'
buildOpenAIUsageRefreshKey
'
,
()
=>
{
it
(
'
会在 codex 快照变化时生成不同 key
'
,
()
=>
{
const
base
=
{
id
:
1
,
platform
:
'
openai
'
,
type
:
'
oauth
'
,
updated_at
:
'
2026-03-07T10:00:00Z
'
,
extra
:
{
codex_usage_updated_at
:
'
2026-03-07T10:00:00Z
'
,
codex_5h_used_percent
:
0
,
codex_7d_used_percent
:
0
}
}
as
any
const
next
=
{
...
base
,
extra
:
{
...
base
.
extra
,
codex_usage_updated_at
:
'
2026-03-07T10:01:00Z
'
,
codex_5h_used_percent
:
100
}
}
expect
(
buildOpenAIUsageRefreshKey
(
base
)).
not
.
toBe
(
buildOpenAIUsageRefreshKey
(
next
))
})
it
(
'
非 OpenAI OAuth 账号返回空 key
'
,
()
=>
{
expect
(
buildOpenAIUsageRefreshKey
({
id
:
2
,
platform
:
'
anthropic
'
,
type
:
'
oauth
'
,
updated_at
:
'
2026-03-07T10:00:00Z
'
,
extra
:
{}
}
as
any
)).
toBe
(
''
)
})
})
frontend/src/utils/accountUsageRefresh.ts
0 → 100644
View file @
0debe0a8
import
type
{
Account
}
from
'
@/types
'
const
normalizeUsageRefreshValue
=
(
value
:
unknown
):
string
=>
{
if
(
value
==
null
)
return
''
return
String
(
value
)
}
export
const
buildOpenAIUsageRefreshKey
=
(
account
:
Pick
<
Account
,
'
id
'
|
'
platform
'
|
'
type
'
|
'
updated_at
'
|
'
rate_limit_reset_at
'
|
'
extra
'
>
):
string
=>
{
if
(
account
.
platform
!==
'
openai
'
||
account
.
type
!==
'
oauth
'
)
{
return
''
}
const
extra
=
account
.
extra
??
{}
return
[
account
.
id
,
account
.
updated_at
,
account
.
rate_limit_reset_at
,
extra
.
codex_usage_updated_at
,
extra
.
codex_5h_used_percent
,
extra
.
codex_5h_reset_at
,
extra
.
codex_5h_reset_after_seconds
,
extra
.
codex_5h_window_minutes
,
extra
.
codex_7d_used_percent
,
extra
.
codex_7d_reset_at
,
extra
.
codex_7d_reset_after_seconds
,
extra
.
codex_7d_window_minutes
].
map
(
normalizeUsageRefreshValue
).
join
(
'
|
'
)
}
frontend/src/views/admin/AccountsView.vue
View file @
0debe0a8
...
...
@@ -309,6 +309,7 @@ import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
import
PlatformTypeBadge
from
'
@/components/common/PlatformTypeBadge.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ErrorPassthroughRulesModal
from
'
@/components/admin/ErrorPassthroughRulesModal.vue
'
import
{
buildOpenAIUsageRefreshKey
}
from
'
@/utils/accountUsageRefresh
'
import
{
formatDateTime
,
formatRelativeTime
}
from
'
@/utils/format
'
import
type
{
Account
,
AccountPlatform
,
AccountType
,
Proxy
,
AdminGroup
,
WindowStats
,
ClaudeModel
}
from
'
@/types
'
...
...
@@ -651,7 +652,8 @@ const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => {
current
.
status
!==
next
.
status
||
current
.
rate_limit_reset_at
!==
next
.
rate_limit_reset_at
||
current
.
overload_until
!==
next
.
overload_until
||
current
.
temp_unschedulable_until
!==
next
.
temp_unschedulable_until
current
.
temp_unschedulable_until
!==
next
.
temp_unschedulable_until
||
buildOpenAIUsageRefreshKey
(
current
)
!==
buildOpenAIUsageRefreshKey
(
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