From df3ca3dcd77f823c7581b160592a2928c5df9388 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 30 Jul 2026 14:09:01 +0800 Subject: [PATCH 1/2] fix(quota): normalize nested Kimi usage windows Read quota values from limits[].detail while preserving legacy outer-field payloads. Normalize TIME_UNIT_* windows and show the five-hour limit before the weekly summary. Refs: #354 --- internal/quota/normalize.go | 50 +++++++++++++++++++++------- internal/quota/test/kimi_test.go | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/internal/quota/normalize.go b/internal/quota/normalize.go index 1a21e120..749ead90 100644 --- a/internal/quota/normalize.go +++ b/internal/quota/normalize.go @@ -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 + } 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 } @@ -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 "" @@ -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": diff --git a/internal/quota/test/kimi_test.go b/internal/quota/test/kimi_test.go index d340e557..e082da22 100644 --- a/internal/quota/test/kimi_test.go +++ b/internal/quota/test/kimi_test.go @@ -3,6 +3,7 @@ package test import ( "context" "encoding/json" + "math" "testing" "cpa-usage-keeper/internal/cpa/dto/apicall" @@ -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) + } +} From b3cf2b1da4407c01562a572a4dcecdf5ca0922f2 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 30 Jul 2026 14:37:19 +0800 Subject: [PATCH 2/2] fix(quota): derive omitted Kimi usage values Derive used from limit and remaining while JSON field presence is still available in the parser. This keeps quota percentages accurate when Kimi omits used. Refs: #354 --- internal/quota/payloads.go | 13 ++++++++++--- internal/quota/test/kimi_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/quota/payloads.go b/internal/quota/payloads.go index 1eec0c37..f777e3dd 100644 --- a/internal/quota/payloads.go +++ b/internal/quota/payloads.go @@ -404,10 +404,17 @@ func parseKimiUsageDetail(object map[string]json.RawMessage) *KimiUsageDetail { if object == nil { return nil } + used, hasUsed := floatValue(object, "used") + limit, hasLimit := floatValue(object, "limit") + remaining, hasRemaining := floatValue(object, "remaining") + // Kimi 省略 used 时,只有 parser 仍能区分字段缺失与显式零值,因此在这里按上游额度关系补齐。 + if !hasUsed && hasLimit && hasRemaining { + used = limit - remaining + } return &KimiUsageDetail{ - Used: floatField(object, "used"), - Limit: floatField(object, "limit"), - Remaining: floatField(object, "remaining"), + Used: used, + Limit: limit, + Remaining: remaining, Name: stringField(object, "name"), Title: stringField(object, "title"), ResetAt: stringField(object, "resetAt", "reset_at", "resetTime", "reset_time"), diff --git a/internal/quota/test/kimi_test.go b/internal/quota/test/kimi_test.go index e082da22..8122631c 100644 --- a/internal/quota/test/kimi_test.go +++ b/internal/quota/test/kimi_test.go @@ -104,6 +104,32 @@ func TestKimiProviderNormalizesNestedFiveHourAndWeeklyUsage(t *testing.T) { } } +func TestKimiProviderDerivesMissingUsedFromLimitAndRemaining(t *testing.T) { + // Kimi 可能省略 used;字段仍在原始 JSON 时应由 limit 与 remaining 推导已用额度。 + body := json.RawMessage(`{"limits":[{"window":{"duration":300,"timeUnit":"TIME_UNIT_MINUTE"},"detail":{"limit":"100","remaining":"85","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) != 1 { + t.Fatalf("expected one five-hour quota row, got %#v", rows) + } + + fiveHour := rows[0] + assertFloatField(t, fiveHour.Used, 15, "five-hour derived used") + assertFloatField(t, fiveHour.Limit, 100, "five-hour limit") + assertFloatField(t, fiveHour.Remaining, 85, "five-hour remaining") + assertApproxFloatField(t, fiveHour.UsedPercent, 15, "five-hour usedPercent") +} + func assertApproxFloatField(t *testing.T, value *float64, expected float64, label string) { t.Helper() if value == nil || math.Abs(*value-expected) > 1e-9 {