diff --git a/plugin/management.go b/plugin/management.go index 2c7af20..7ae4aa2 100644 --- a/plugin/management.go +++ b/plugin/management.go @@ -182,7 +182,16 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI }, nil } - usage, errParse := ParseAndFormatUsage(raw, time.Now().UTC()) + // Fetch billing-period (monthly) usage totals; non-fatal if unavailable. + var summary *UpstreamUsageSummaryResponse + if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK { + var parsed UpstreamUsageSummaryResponse + if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 { + summary = &parsed + } + } + + usage, errParse := ParseAndFormatUsage(raw, summary, time.Now().UTC()) if errParse != nil { resBytes, _ := json.Marshal(map[string]any{ "ok": false, diff --git a/plugin/quota_page.go b/plugin/quota_page.go index be5b2af..e64966e 100644 --- a/plugin/quota_page.go +++ b/plugin/quota_page.go @@ -676,6 +676,37 @@ const QuotaPageHTML = `
+ +
+
+
+ 账单周期 +
月度额度 (Monthly Window)
+
+
- %
+
+ +
+
+ - + / - +
+
剩余可用: -
+
+ +
+
+
+ +
+
+ + 账单周期重置 +
+
--:--:--
+
+
+
@@ -770,6 +801,14 @@ const QuotaPageHTML = ` const valOpensourceCredits = document.getElementById("valOpensourceCredits"); const valTotalCredits = document.getElementById("valTotalCredits"); + const cardMonthly = document.getElementById("cardMonthly"); + const badgeMonthly = document.getElementById("badgeMonthly"); + const usedMonthly = document.getElementById("usedMonthly"); + const capMonthly = document.getElementById("capMonthly"); + const remainMonthly = document.getElementById("remainMonthly"); + const barMonthly = document.getElementById("barMonthly"); + const timerMonthly = document.getElementById("timerMonthly"); + const cardFiveHour = document.getElementById("cardFiveHour"); const badgeFiveHour = document.getElementById("badgeFiveHour"); const usedFiveHour = document.getElementById("usedFiveHour"); @@ -787,6 +826,7 @@ const QuotaPageHTML = ` const timerWeekly = document.getElementById("timerWeekly"); const lastUpdated = document.getElementById("lastUpdated"); + let monthlyTargetTime = null; let fiveHourTargetTime = null; let weeklyTargetTime = null; let timerInterval = null; @@ -842,6 +882,9 @@ const QuotaPageHTML = ` } function updateTimers() { + if (monthlyTargetTime) { + timerMonthly.textContent = formatCountdown(monthlyTargetTime); + } if (fiveHourTargetTime) { timerFiveHour.textContent = formatCountdown(fiveHourTargetTime); } @@ -860,6 +903,20 @@ const QuotaPageHTML = ` valOpensourceCredits.textContent = formatNumber(credits.opensource_monthly_credits); valTotalCredits.textContent = formatNumber(credits.total_credits); + // Monthly (billing period) + const monthly = limits.monthly || {}; + const pMonth = Math.min(100, Math.max(0, monthly.percentage || 0)); + badgeMonthly.textContent = pMonth.toFixed(1) + "%"; + usedMonthly.textContent = formatNumber(monthly.used); + capMonthly.textContent = "/ " + formatNumber(monthly.cap); + remainMonthly.textContent = formatNumber(monthly.remaining); + barMonthly.style.width = pMonth + "%"; + + barMonthly.className = "progress-bar" + (pMonth >= 90 || monthly.exceeded ? " danger" : pMonth >= 70 ? " warning" : ""); + cardMonthly.className = "quota-card" + (monthly.exceeded ? " is-exceeded" : ""); + monthlyTargetTime = null; + timerMonthly.textContent = "账单周期"; + // Five Hour const fiveHour = limits.five_hour || {}; const pFive = Math.min(100, Math.max(0, fiveHour.percentage || 0)); @@ -901,10 +958,10 @@ const QuotaPageHTML = ` } // Overall Status - if (fiveHour.exceeded || weekly.exceeded) { + if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) { statusBadge.className = "status-badge exceeded"; statusText.textContent = "已达限额 (Exceeded)"; - } else if (pFive >= 80 || pWeek >= 80) { + } else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) { statusBadge.className = "status-badge warning"; statusText.textContent = "配额紧张 (Warning)"; } else { diff --git a/plugin/types.go b/plugin/types.go index 2bc5961..7d4cc94 100644 --- a/plugin/types.go +++ b/plugin/types.go @@ -256,8 +256,20 @@ type UsageWindowLimitData struct { ResetInSeconds int64 `json:"reset_in_seconds"` } +// UpstreamUsageSummaryResponse reflects Command Code's /internal/usage/summary payload, +// which aggregates usage over the current billing period (monthly). +type UpstreamUsageSummaryResponse struct { + TotalCount int64 `json:"totalCount"` + TotalCost float64 `json:"totalCost"` + TotalCredits float64 `json:"totalCredits"` + TotalMonthlyCredits float64 `json:"totalMonthlyCredits"` + TotalPurchasedCredits float64 `json:"totalPurchasedCredits"` + PeriodBasis string `json:"periodBasis"` +} + // UsageWindowLimitsData contains both windows. type UsageWindowLimitsData struct { + Monthly UsageWindowLimitData `json:"monthly"` FiveHour UsageWindowLimitData `json:"five_hour"` Weekly UsageWindowLimitData `json:"weekly"` } diff --git a/plugin/usage.go b/plugin/usage.go index f6d7121..a8da2e3 100644 --- a/plugin/usage.go +++ b/plugin/usage.go @@ -42,8 +42,9 @@ func SetDefaultHTTPClient(client HTTPDoer) { } } -// FetchCreditsRaw fetches raw upstream credit data via host.http.do or net/http fallback. -func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCallbackID string) ([]byte, int, error) { +// fetchUpstream performs a GET on an internal Command Code endpoint, reusing +// the host.http.do bridge when available, else falling back to net/http. +func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCallbackID string) ([]byte, int, error) { cleanToken := ExtractSessionToken(sessionToken) if cleanToken == "" { return nil, http.StatusBadRequest, errors.New("missing session_token: please provide a valid Command Code session token") @@ -52,7 +53,7 @@ func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCall if apiBase == "" { apiBase = DefaultAPIBase } - url := fmt.Sprintf("%s/internal/billing/credits", strings.TrimRight(apiBase, "/")) + url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/")) cookieValue := FormatSessionCookie(cleanToken) // 1. Try host.http.do if hostCaller is configured @@ -120,8 +121,19 @@ func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCall return body, res.StatusCode, nil } +// FetchCreditsRaw fetches raw upstream credit data via host.http.do or net/http fallback. +func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCallbackID string) ([]byte, int, error) { + return fetchUpstream(ctx, apiBase, "internal/billing/credits", sessionToken, hostCallbackID) +} + +// FetchUsageSummaryRaw fetches the billing-period (monthly) usage totals. +func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hostCallbackID string) ([]byte, int, error) { + return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID) +} + // ParseAndFormatUsage parses upstream credits JSON into structured usage metrics. -func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, error) { +// summary (optional) carries the billing-period usage totals used to derive the monthly window. +func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now time.Time) (*FormattedUsageResponse, error) { if len(raw) == 0 { return nil, errors.New("empty response body from upstream") } @@ -149,6 +161,7 @@ func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, er // Format window limits windowLimitsData := formatWindowLimits(upstream.WindowLimits, now) + windowLimitsData.Monthly = formatMonthlyWindow(upstream.Credits, summary, now) nowRFC := now.Format(time.RFC3339) data := FormattedUsageData{ @@ -188,6 +201,41 @@ func formatWindowLimits(upstream UpstreamWindowLimits, now time.Time) UsageWindo } } +// formatMonthlyWindow derives the monthly (billing period) window from the +// billing/credits response (remaining monthlyCredits) and the +// /internal/usage/summary response (totalMonthlyCredits consumed this period). +// cap = consumed + remaining, used = consumed, remaining = monthlyCredits. +// Returns a zero window when the summary (consumed totals) is unavailable, +// because a monthly window cannot be derived from remaining credits alone. +func formatMonthlyWindow(credits map[string]any, summary *UpstreamUsageSummaryResponse, now time.Time) UsageWindowLimitData { + out := UsageWindowLimitData{} + if summary == nil || summary.TotalMonthlyCredits <= 0 { + return out + } + + remaining := getFloatFromMap(credits, "monthlyCredits", "monthly_credits") + used := summary.TotalMonthlyCredits + + capTotal := used + remaining + if capTotal > 0 { + percentage := (used / capTotal) * 100.0 + if percentage > 100.0 { + percentage = 100.0 + } + out.Percentage = math.Round(percentage*100) / 100 + } + + out.Used = used + out.Cap = capTotal + out.Remaining = remaining + if out.Remaining < 0 { + out.Remaining = 0 + } + out.ResetAt = "" // 账单周期重置时间上游未提供 + out.ResetInSeconds = 0 + return out +} + func formatSingleWindow(w UpstreamWindowLimit, now time.Time) UsageWindowLimitData { var remaining float64 var percentage float64 diff --git a/plugin/usage_test.go b/plugin/usage_test.go index 0743862..ab2e080 100644 --- a/plugin/usage_test.go +++ b/plugin/usage_test.go @@ -3,6 +3,7 @@ package plugin import ( "context" "encoding/json" + "math" "net/http" "net/http/httptest" "testing" @@ -81,7 +82,44 @@ func TestParseAndFormatUsage(t *testing.T) { }`) now := time.Date(2025, 3, 4, 12, 0, 0, 0, time.UTC) - usage, err := ParseAndFormatUsage(raw, now) + + t.Run("without summary monthly defaults empty", func(t *testing.T) { + usage, err := ParseAndFormatUsage(raw, nil, now) + if err != nil { + t.Fatalf("ParseAndFormatUsage error: %v", err) + } + if !usage.OK { + t.Fatal("expected OK=true") + } + if usage.WindowLimits.Monthly.Used != 0 || usage.WindowLimits.Monthly.Cap != 0 { + t.Errorf("expected empty monthly without summary, got %+v", usage.WindowLimits.Monthly) + } + }) + + t.Run("monthly window derived from summary + remaining credits", func(t *testing.T) { + summary := &UpstreamUsageSummaryResponse{TotalMonthlyCredits: 30.0} + // monthlyCredits in raw = 1000 remaining, so cap = 1030 + usage, err := ParseAndFormatUsage(raw, summary, now) + if err != nil { + t.Fatalf("ParseAndFormatUsage error: %v", err) + } + m := usage.WindowLimits.Monthly + if m.Used != 30.0 { + t.Errorf("monthly used = %v, want 30", m.Used) + } + if m.Cap != 1030.0 { + t.Errorf("monthly cap = %v, want 1030", m.Cap) + } + if m.Remaining != 1000.0 { + t.Errorf("monthly remaining = %v, want 1000", m.Remaining) + } + wantPct := math.Round((30.0/1030.0)*10000) / 100 + if m.Percentage != wantPct { + t.Errorf("monthly percentage = %v, want %v", m.Percentage, wantPct) + } + }) + + usage, err := ParseAndFormatUsage(raw, nil, now) if err != nil { t.Fatalf("ParseAndFormatUsage error: %v", err) } @@ -174,7 +212,7 @@ func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) { t.Fatal("expected non-empty body") } - usage, errParse := ParseAndFormatUsage(body, time.Time{}) + usage, errParse := ParseAndFormatUsage(body, nil, time.Time{}) if errParse != nil { t.Fatalf("ParseAndFormatUsage error: %v", errParse) }