Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 10 additions & 3 deletions internal/quota/payloads.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
82 changes: 82 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,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)
}
}