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/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 d340e557..8122631c 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,84 @@ 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 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 { + t.Fatalf("unexpected %s: got %#v want %v", label, value, expected) + } +}