mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 03:32:50 +08:00
feat: derive monthly (billing period) usage window
- Add FetchUsageSummaryRaw calling /internal/usage/summary which returns billing-period totals (periodBasis=billing-period) - Derive monthly window: used=totalMonthlyCredits, cap=totalMonthlyCredits + monthlyCredits remaining (≈ plan monthly total), remaining=monthlyCredits - Add monthly card to quota resource page (HTML + JS rendering) - Skip monthly card when summary unavailable
This commit is contained in:
+10
-1
@@ -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,
|
||||
|
||||
+59
-2
@@ -676,6 +676,37 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
|
||||
<!-- Double Window Limits -->
|
||||
<div class="quota-section">
|
||||
<!-- Monthly Window -->
|
||||
<div id="cardMonthly" class="quota-card">
|
||||
<div class="quota-card-header">
|
||||
<div>
|
||||
<span class="quota-tag" style="color:#f59e0b; background:rgba(245,158,11,0.12)">账单周期</span>
|
||||
<div class="quota-name">月度额度 (Monthly Window)</div>
|
||||
</div>
|
||||
<div id="badgeMonthly" class="quota-percent-badge">- %</div>
|
||||
</div>
|
||||
|
||||
<div class="quota-stats-row">
|
||||
<div>
|
||||
<span id="usedMonthly" class="quota-usage-num">-</span>
|
||||
<span id="capMonthly" class="quota-cap-num">/ -</span>
|
||||
</div>
|
||||
<div>剩余可用: <strong id="remainMonthly">-</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-track">
|
||||
<div id="barMonthly" class="progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="reset-box">
|
||||
<div class="reset-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
<span>账单周期重置</span>
|
||||
</div>
|
||||
<div id="timerMonthly" class="countdown-timer">--:--:--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5-Hour Window -->
|
||||
<div id="cardFiveHour" class="quota-card">
|
||||
<div class="quota-card-header">
|
||||
@@ -770,6 +801,14 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
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 = `<!DOCTYPE html>
|
||||
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 = `<!DOCTYPE html>
|
||||
}
|
||||
|
||||
function updateTimers() {
|
||||
if (monthlyTargetTime) {
|
||||
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
|
||||
}
|
||||
if (fiveHourTargetTime) {
|
||||
timerFiveHour.textContent = formatCountdown(fiveHourTargetTime);
|
||||
}
|
||||
@@ -860,6 +903,20 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
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 = `<!DOCTYPE html>
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
+52
-4
@@ -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
|
||||
|
||||
+40
-2
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user