feat: show current-cycle token totals
This commit is contained in:
+3
-1
@@ -31,13 +31,15 @@
|
||||
account:{email:string|null, authMode:string|null, planType:string|null, connected:bool},
|
||||
limits:[{limitId, limitName:string|null, windowType,
|
||||
usedPercent, windowDurationMinutes, resetsAt, planType:string|null}],
|
||||
currentCycle?:{limitId, windowType, windowDurationMinutes,
|
||||
startedAt, resetsAt, totalTokens},
|
||||
summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?,
|
||||
currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?},
|
||||
usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}],
|
||||
fetchedAt, stale, lastError?}
|
||||
```
|
||||
|
||||
时间字段为 Unix 秒。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。
|
||||
时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。没有有效窗口或用量接口失败时省略该字段。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。
|
||||
|
||||
## 3. 系统、初始化与会话
|
||||
|
||||
|
||||
@@ -460,6 +460,7 @@ func (a *App) syncAccount(ctx context.Context, id int64) error {
|
||||
d.Usage = append(d.Usage, p)
|
||||
_, _ = a.store.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", id, x.StartDate, x.Tokens, d.FetchedAt)
|
||||
}
|
||||
d.CurrentCycle = currentTokenCycle(d.Limits, d.Usage, d.FetchedAt)
|
||||
}
|
||||
resetDetected, e := a.storeLimitSnapshots(d)
|
||||
if e != nil {
|
||||
@@ -530,6 +531,58 @@ type rawLimit struct {
|
||||
Secondary *LimitWindow `json:"secondary"`
|
||||
}
|
||||
|
||||
func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle {
|
||||
current := LimitBucket{}
|
||||
found := false
|
||||
for _, limit := range limits {
|
||||
if limit.WindowDurationMinutes <= 0 || limit.ResetsAt <= fetchedAt {
|
||||
continue
|
||||
}
|
||||
if !found || betterTokenCycleLimit(limit, current) {
|
||||
current = limit
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
startedAt := current.ResetsAt - int64(current.WindowDurationMinutes)*60
|
||||
startDate := time.Unix(startedAt, 0).UTC().Format("2006-01-02")
|
||||
endDate := time.Unix(fetchedAt, 0).UTC().Format("2006-01-02")
|
||||
var total int64
|
||||
for _, point := range usage {
|
||||
if point.Date < startDate || point.Date > endDate {
|
||||
continue
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", point.Date); err != nil {
|
||||
continue
|
||||
}
|
||||
total += point.TotalTokens
|
||||
}
|
||||
return &TokenCycle{
|
||||
LimitID: current.LimitID,
|
||||
WindowType: current.WindowType,
|
||||
WindowDurationMinutes: current.WindowDurationMinutes,
|
||||
StartedAt: startedAt,
|
||||
ResetsAt: current.ResetsAt,
|
||||
TotalTokens: total,
|
||||
}
|
||||
}
|
||||
|
||||
func betterTokenCycleLimit(candidate, current LimitBucket) bool {
|
||||
if candidate.WindowDurationMinutes != current.WindowDurationMinutes {
|
||||
return candidate.WindowDurationMinutes > current.WindowDurationMinutes
|
||||
}
|
||||
if candidate.WindowType != current.WindowType {
|
||||
return candidate.WindowType == "secondary"
|
||||
}
|
||||
if candidate.LimitID != current.LimitID {
|
||||
return candidate.LimitID < current.LimitID
|
||||
}
|
||||
return candidate.ResetsAt > current.ResetsAt
|
||||
}
|
||||
|
||||
func flattenLimit(x rawLimit) []LimitBucket {
|
||||
out := []LimitBucket{}
|
||||
if x.Primary != nil {
|
||||
|
||||
@@ -71,6 +71,72 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
|
||||
reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
|
||||
cycle := currentTokenCycle(
|
||||
[]LimitBucket{
|
||||
{
|
||||
LimitID: "codex",
|
||||
WindowType: "primary",
|
||||
WindowDurationMinutes: 300,
|
||||
ResetsAt: now.Add(5 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
LimitID: "codex",
|
||||
WindowType: "secondary",
|
||||
WindowDurationMinutes: 7 * 24 * 60,
|
||||
ResetsAt: reset.Unix(),
|
||||
},
|
||||
},
|
||||
[]UsagePoint{
|
||||
{Date: "2026-08-07", TotalTokens: 50},
|
||||
{Date: "2026-08-08", TotalTokens: 100},
|
||||
{Date: "2026-08-10", TotalTokens: 200},
|
||||
{Date: "2026-08-14", TotalTokens: 300},
|
||||
{Date: "2026-08-15", TotalTokens: 400},
|
||||
},
|
||||
now.Unix(),
|
||||
)
|
||||
if cycle == nil {
|
||||
t.Fatal("currentTokenCycle() returned nil")
|
||||
}
|
||||
if cycle.WindowType != "secondary" || cycle.WindowDurationMinutes != 7*24*60 {
|
||||
t.Fatalf("cycle window = %#v; want the seven-day secondary window", cycle)
|
||||
}
|
||||
if cycle.StartedAt != time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix() {
|
||||
t.Fatalf("cycle start = %d; want 2026-08-08", cycle.StartedAt)
|
||||
}
|
||||
if cycle.ResetsAt != reset.Unix() || cycle.TotalTokens != 600 {
|
||||
t.Fatalf("cycle = %#v; want reset %d and 600 tokens", cycle, reset.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) {
|
||||
cycle := currentTokenCycle(
|
||||
[]LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}},
|
||||
[]UsagePoint{{Date: "2026-08-14", TotalTokens: 100}},
|
||||
time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC).Unix(),
|
||||
)
|
||||
if cycle != nil {
|
||||
t.Fatalf("currentTokenCycle() = %#v; want nil", cycle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) {
|
||||
var limit rawLimit
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"limitId":"codex",
|
||||
"primary":{"usedPercent":20,"windowDurationMins":10080,"resetsAt":1787197043}
|
||||
}`), &limit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flattened := flattenLimit(limit)
|
||||
if len(flattened) != 1 || flattened[0].WindowDurationMinutes != 10080 {
|
||||
t.Fatalf("flattened limit = %#v; want a 10080-minute window", flattened)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCodexClient struct {
|
||||
mu sync.Mutex
|
||||
connected bool
|
||||
|
||||
@@ -41,7 +41,7 @@ type AccountView struct {
|
||||
}
|
||||
type LimitWindow struct {
|
||||
UsedPercent float64 `json:"usedPercent"`
|
||||
WindowDurationMins int `json:"windowDurationMinutes"`
|
||||
WindowDurationMins int `json:"windowDurationMins"`
|
||||
ResetsAt int64 `json:"resetsAt"`
|
||||
}
|
||||
type LimitBucket struct {
|
||||
@@ -70,16 +70,25 @@ type UsagePoint struct {
|
||||
InputTokens *int64 `json:"inputTokens"`
|
||||
OutputTokens *int64 `json:"outputTokens"`
|
||||
}
|
||||
type TokenCycle struct {
|
||||
LimitID string `json:"limitId"`
|
||||
WindowType string `json:"windowType"`
|
||||
WindowDurationMinutes int `json:"windowDurationMinutes"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
ResetsAt int64 `json:"resetsAt"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
}
|
||||
type Dashboard struct {
|
||||
AccountID int64 `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Account AccountView `json:"account"`
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Summary UsageSummary `json:"summary"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
Stale bool `json:"stale"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
AccountID int64 `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Account AccountView `json:"account"`
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Summary UsageSummary `json:"summary"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
CurrentCycle *TokenCycle `json:"currentCycle,omitempty"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
Stale bool `json:"stale"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
func defaults() GeneralSettings {
|
||||
|
||||
Reference in New Issue
Block a user