Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions internal/quota/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,42 +365,50 @@ func antigravityQuotaWindowOrder(metric string) int {
}

func normalizeKimiQuotaRows(result KimiResult) []QuotaRow {
// Kimi 的 summary 和 limits 结构不同,先保留 summary,再逐条展开 limits
// Kimi 的短窗口位于 limits,顶层 usage 是较长的汇总窗口;按短窗口到长窗口输出
if result.Usage == nil {
return nil
}
rows := make([]QuotaRow, 0, 1+len(result.Usage.Limits))
if isMeaningfulKimiDetail(result.Usage.Usage) {
rows = append(rows, kimiDetailQuotaRow("usage", "summary", "Usage", result.Usage.Usage))
}
for index, limit := range result.Usage.Limits {
keyName := limit.Name
if keyName == "" {
keyName = fmt.Sprintf("%d", index)
}
label := firstNonEmpty(limit.Title, limit.Name, "Limit")
window := kimiWindow(limit)
label := kimiLimitLabel(limit, window)
scope := firstNonEmpty(limit.Scope, "limit")
used, quotaLimit, remaining := limit.Used, limit.Limit, limit.Remaining
// 新协议把额度值放在 detail;旧协议只把 reset 信息放在 detail,额度仍在外层。
if hasKimiQuotaValues(limit.Detail) {
used = limit.Detail.Used
quotaLimit = limit.Detail.Limit
remaining = limit.Detail.Remaining
Comment thread
Willxup marked this conversation as resolved.
}
row := QuotaRow{
Key: "limits." + keyName,
Label: label,
Scope: scope,
Metric: limit.Name,
Used: floatPtr(limit.Used),
Limit: floatPtr(limit.Limit),
Remaining: floatPtr(limit.Remaining),
Used: floatPtr(used),
Limit: floatPtr(quotaLimit),
Remaining: floatPtr(remaining),
ResetAt: firstNonEmpty(limit.ResetAt, resetAtFromKimiDetail(limit.Detail)),
}
if limit.Limit > 0 {
row.UsedPercent = floatPtr(limit.Used / limit.Limit * 100)
if quotaLimit > 0 {
row.UsedPercent = floatPtr(used / quotaLimit * 100)
}
if limit.ResetIn != 0 {
row.ResetAfterSeconds = intPtr(int64(limit.ResetIn))
} else if limit.Detail != nil && limit.Detail.ResetIn != 0 {
row.ResetAfterSeconds = intPtr(int64(limit.Detail.ResetIn))
}
row.Window = kimiWindow(limit)
row.Window = window
rows = append(rows, row)
}
if isMeaningfulKimiDetail(result.Usage.Usage) {
rows = append(rows, kimiDetailQuotaRow("usage", "summary", "Weekly", result.Usage.Usage))
}
return rows
}

Expand Down Expand Up @@ -606,6 +614,10 @@ func isMeaningfulKimiDetail(detail *KimiUsageDetail) bool {
return detail.Used != 0 || detail.Limit != 0 || detail.Remaining != 0 || detail.Name != "" || detail.Title != "" || detail.ResetAt != "" || detail.ResetIn != 0 || detail.TTL != 0
}

func hasKimiQuotaValues(detail *KimiUsageDetail) bool {
return detail != nil && (detail.Used != 0 || detail.Limit != 0 || detail.Remaining != 0)
}

func resetAtFromKimiDetail(detail *KimiUsageDetail) string {
if detail == nil {
return ""
Expand All @@ -623,10 +635,24 @@ func kimiWindow(limit KimiLimitItem) *QuotaWindow {
return nil
}

func kimiLimitLabel(limit KimiLimitItem, window *QuotaWindow) string {
if label := firstNonEmpty(limit.Title, limit.Name); label != "" {
return label
}
if window != nil && window.Seconds != nil {
switch *window.Seconds {
case quotaWindowFiveHourSeconds:
return "5h"
}
}
return "Limit"
}

func quotaWindowFromDurationUnit(duration int64, unit string) *QuotaWindow {
window := &QuotaWindow{Duration: floatPtr(float64(duration)), Unit: unit}
// Kimi 返回显式 duration/unit 时才换算 seconds;未知单位保留原字段但不参与窗口用量统计。
switch strings.ToLower(strings.TrimSpace(unit)) {
normalizedUnit := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(unit)), "time_unit_")
switch normalizedUnit {
case "second", "seconds", "s":
window.Seconds = intPtr(int64(duration))
case "minute", "minutes", "m":
Expand Down
56 changes: 56 additions & 0 deletions internal/quota/test/kimi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package test
import (
"context"
"encoding/json"
"math"
"testing"

"cpa-usage-keeper/internal/cpa/dto/apicall"
Expand Down Expand Up @@ -54,3 +55,58 @@ func TestKimiProviderCallsUsageRequest(t *testing.T) {
t.Fatalf("expected no data body, got %#v", request.Data)
}
}

func TestKimiProviderNormalizesNestedFiveHourAndWeeklyUsage(t *testing.T) {
// #354 的真实响应把短窗口用量放在 detail,数值使用字符串,窗口单位使用枚举值。
body := json.RawMessage(`{"usage":{"limit":"100","used":"28","remaining":"72","resetTime":"2026-07-31T02:59:25.127311Z"},"limits":[{"window":{"duration":300,"timeUnit":"TIME_UNIT_MINUTE"},"detail":{"limit":"100","used":"92","remaining":"8","resetTime":"2026-07-24T12:59:25.127311Z"}}]}`)
caller := &recordingManagementCaller{responses: []*apicall.Response{{
StatusCode: 200,
BodyText: string(body),
Body: body,
}}}
provider := quota.NewKimiProvider(caller, quota.DefaultProviderConfigs().Kimi)

output, err := provider.Check(context.Background(), quota.ProviderInput{Identity: entities.UsageIdentity{Identity: "kimi-auth"}})
if err != nil {
t.Fatalf("Check returned error: %v", err)
}
rows := quota.NormalizeQuotaRows(output)
if len(rows) != 2 {
t.Fatalf("expected five-hour and weekly quota rows, got %#v", rows)
}

fiveHour := rows[0]
if fiveHour.Key != "limits.0" || fiveHour.Label != "5h" {
t.Fatalf("expected five-hour row first, got %#v", fiveHour)
}
assertFloatField(t, fiveHour.Used, 92, "five-hour used")
assertFloatField(t, fiveHour.Limit, 100, "five-hour limit")
assertFloatField(t, fiveHour.Remaining, 8, "five-hour remaining")
assertApproxFloatField(t, fiveHour.UsedPercent, 92, "five-hour usedPercent")
if fiveHour.Window == nil {
t.Fatalf("expected five-hour window, got %#v", fiveHour)
}
assertIntField(t, fiveHour.Window.Seconds, 5*60*60, "five-hour window seconds")
if fiveHour.ResetAt != "2026-07-24T12:59:25.127311Z" {
t.Fatalf("unexpected five-hour resetAt: %#v", fiveHour)
}

weekly := rows[1]
if weekly.Key != "usage" || weekly.Label != "Weekly" {
t.Fatalf("expected weekly row second, got %#v", weekly)
}
assertFloatField(t, weekly.Used, 28, "weekly used")
assertFloatField(t, weekly.Limit, 100, "weekly limit")
assertFloatField(t, weekly.Remaining, 72, "weekly remaining")
assertApproxFloatField(t, weekly.UsedPercent, 28, "weekly usedPercent")
if weekly.ResetAt != "2026-07-31T02:59:25.127311Z" {
t.Fatalf("unexpected weekly resetAt: %#v", weekly)
}
}

func assertApproxFloatField(t *testing.T, value *float64, expected float64, label string) {
t.Helper()
if value == nil || math.Abs(*value-expected) > 1e-9 {
t.Fatalf("unexpected %s: got %#v want %v", label, value, expected)
}
}