3 Commits
Author SHA1 Message Date
yuez 4adda19b72 fix: correct invalid CSS nesting for plan-tag dark rule, bump page version v0.2.0
- Split malformed ':root[data-theme=dark] .plan-tag, @media {...}'
  selector list into two valid rules (attribute rule + media rule)
- Bump version-tag displayed on quota page to v0.2.0
2026-09-04 14:25:08 +08:00
yuez dc6c2d18b3 feat: add subscription plan inference and Management Center theme sync 2026-09-04 14:22:56 +08:00
yuez 688a7f395f 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
2026-09-04 14:13:08 +08:00
7 changed files with 695 additions and 11 deletions
+10 -1
View File
@@ -182,7 +182,16 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
}, nil }, 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 { if errParse != nil {
resBytes, _ := json.Marshal(map[string]any{ resBytes, _ := json.Marshal(map[string]any{
"ok": false, "ok": false,
+214 -4
View File
@@ -37,8 +37,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
--radius-lg: 16px; --radius-lg: 16px;
} }
/* Auto mode fallback: when no explicit data-theme is set, follow OS dark preference */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme="light"]):not([data-theme="white"]) {
--bg-page: #0b0f19; --bg-page: #0b0f19;
--bg-card: #151d30; --bg-card: #151d30;
--bg-subtle: #1e293b; --bg-subtle: #1e293b;
@@ -61,6 +62,53 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
} }
/* Explicit dark theme from Management Center */
:root[data-theme="dark"] {
--bg-page: #0b0f19;
--bg-card: #151d30;
--bg-subtle: #1e293b;
--border-color: #334155;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-dim: #64748b;
--primary: #60a5fa;
--primary-hover: #3b82f6;
--primary-subtle: rgba(59, 130, 246, 0.12);
--success: #34d399;
--success-subtle: rgba(16, 185, 129, 0.12);
--warning: #fbbf24;
--warning-subtle: rgba(245, 158, 11, 0.12);
--danger: #f87171;
--danger-subtle: rgba(239, 68, 68, 0.12);
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
}
/* Explicit white / light theme from Management Center: overrides OS dark preference */
:root[data-theme="white"],
:root[data-theme="light"] {
--bg-page: #f8fafc;
--bg-card: #ffffff;
--bg-subtle: #f1f5f9;
--border-color: #e2e8f0;
--text-main: #0f172a;
--text-muted: #64748b;
--text-dim: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-subtle: #eff6ff;
--success: #10b981;
--success-subtle: #ecfdf5;
--warning: #f59e0b;
--warning-subtle: #fffbeb;
--danger: #ef4444;
--danger-subtle: #fef2f2;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -141,6 +189,34 @@ const QuotaPageHTML = `<!DOCTYPE html>
border: 1px solid var(--primary); border: 1px solid var(--primary);
} }
.plan-tag {
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.15));
color: #8b5cf6;
border: 1px solid rgba(139, 92, 246, 0.35);
display: inline-flex;
align-items: center;
gap: 4px;
letter-spacing: 0.3px;
}
:root[data-theme="dark"] .plan-tag {
background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(96, 165, 250, 0.2));
color: #c084fc;
border-color: rgba(167, 139, 250, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]):not([data-theme="white"]) .plan-tag {
background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(96, 165, 250, 0.2));
color: #c084fc;
border-color: rgba(167, 139, 250, 0.45);
}
}
.brand-subtitle { .brand-subtitle {
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
@@ -587,7 +663,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
<div> <div>
<div class="brand-title"> <div class="brand-title">
Command Code 配额 Command Code 配额
<span class="version-tag">v0.1.0</span> <span class="version-tag">v0.2.0</span>
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
</div> </div>
<div class="brand-subtitle">CLIProxyAPI 实时限额与 Credits 用量监控</div> <div class="brand-subtitle">CLIProxyAPI 实时限额与 Credits 用量监控</div>
</div> </div>
@@ -676,6 +753,37 @@ const QuotaPageHTML = `<!DOCTYPE html>
<!-- Double Window Limits --> <!-- Double Window Limits -->
<div class="quota-section"> <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 --> <!-- 5-Hour Window -->
<div id="cardFiveHour" class="quota-card"> <div id="cardFiveHour" class="quota-card">
<div class="quota-card-header"> <div class="quota-card-header">
@@ -765,11 +873,20 @@ const QuotaPageHTML = `<!DOCTYPE html>
const alertMsg = document.getElementById("alertMsg"); const alertMsg = document.getElementById("alertMsg");
const statusBadge = document.getElementById("statusBadge"); const statusBadge = document.getElementById("statusBadge");
const statusText = document.getElementById("statusText"); const statusText = document.getElementById("statusText");
const planBadge = document.getElementById("planBadge");
const valMonthlyCredits = document.getElementById("valMonthlyCredits"); const valMonthlyCredits = document.getElementById("valMonthlyCredits");
const valOpensourceCredits = document.getElementById("valOpensourceCredits"); const valOpensourceCredits = document.getElementById("valOpensourceCredits");
const valTotalCredits = document.getElementById("valTotalCredits"); 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 cardFiveHour = document.getElementById("cardFiveHour");
const badgeFiveHour = document.getElementById("badgeFiveHour"); const badgeFiveHour = document.getElementById("badgeFiveHour");
const usedFiveHour = document.getElementById("usedFiveHour"); const usedFiveHour = document.getElementById("usedFiveHour");
@@ -787,6 +904,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
const timerWeekly = document.getElementById("timerWeekly"); const timerWeekly = document.getElementById("timerWeekly");
const lastUpdated = document.getElementById("lastUpdated"); const lastUpdated = document.getElementById("lastUpdated");
let monthlyTargetTime = null;
let fiveHourTargetTime = null; let fiveHourTargetTime = null;
let weeklyTargetTime = null; let weeklyTargetTime = null;
let timerInterval = null; let timerInterval = null;
@@ -842,6 +960,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
function updateTimers() { function updateTimers() {
if (monthlyTargetTime) {
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
}
if (fiveHourTargetTime) { if (fiveHourTargetTime) {
timerFiveHour.textContent = formatCountdown(fiveHourTargetTime); timerFiveHour.textContent = formatCountdown(fiveHourTargetTime);
} }
@@ -855,11 +976,35 @@ const QuotaPageHTML = `<!DOCTYPE html>
const credits = data.credits || (data.data && data.data.credits) || {}; const credits = data.credits || (data.data && data.data.credits) || {};
const limits = data.window_limits || (data.data && data.data.window_limits) || {}; const limits = data.window_limits || (data.data && data.data.window_limits) || {};
// Plan
const plan = data.plan || (data.data && data.data.plan);
if (plan) {
const planName = plan.name || (typeof plan === "string" ? plan : "Unknown");
planBadge.textContent = "Plan: " + planName;
planBadge.style.display = "inline-flex";
} else {
planBadge.style.display = "none";
}
// Credits // Credits
valMonthlyCredits.textContent = formatNumber(credits.monthly_credits); valMonthlyCredits.textContent = formatNumber(credits.monthly_credits);
valOpensourceCredits.textContent = formatNumber(credits.opensource_monthly_credits); valOpensourceCredits.textContent = formatNumber(credits.opensource_monthly_credits);
valTotalCredits.textContent = formatNumber(credits.total_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 // Five Hour
const fiveHour = limits.five_hour || {}; const fiveHour = limits.five_hour || {};
const pFive = Math.min(100, Math.max(0, fiveHour.percentage || 0)); const pFive = Math.min(100, Math.max(0, fiveHour.percentage || 0));
@@ -901,10 +1046,10 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
// Overall Status // Overall Status
if (fiveHour.exceeded || weekly.exceeded) { if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
statusBadge.className = "status-badge exceeded"; statusBadge.className = "status-badge exceeded";
statusText.textContent = "已达限额 (Exceeded)"; statusText.textContent = "已达限额 (Exceeded)";
} else if (pFive >= 80 || pWeek >= 80) { } else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) {
statusBadge.className = "status-badge warning"; statusBadge.className = "status-badge warning";
statusText.textContent = "配额紧张 (Warning)"; statusText.textContent = "配额紧张 (Warning)";
} else { } else {
@@ -1005,6 +1150,71 @@ const QuotaPageHTML = `<!DOCTYPE html>
inputMgmtKey.value = initKey; inputMgmtKey.value = initKey;
} }
function syncTheme() {
let themeSetting = null;
// 1. Try reading from parent window (if same-origin iframe)
try {
if (window.parent && window.parent !== window && window.parent.document) {
const parentTheme = window.parent.document.documentElement.getAttribute("data-theme");
if (parentTheme) {
themeSetting = parentTheme;
}
}
} catch (e) {}
// 2. Try reading from localStorage['cli-proxy-theme']
if (!themeSetting) {
try {
const stored = localStorage.getItem("cli-proxy-theme");
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && parsed.state) {
if (parsed.state.theme) {
themeSetting = parsed.state.theme;
}
if (parsed.state.resolvedTheme && themeSetting === "auto") {
themeSetting = parsed.state.resolvedTheme;
}
}
}
} catch (e) {}
}
// 3. Fallback to own html attribute
if (!themeSetting) {
themeSetting = document.documentElement.getAttribute("data-theme");
}
// Apply theme to documentElement
const docEl = document.documentElement;
if (themeSetting === "dark") {
docEl.setAttribute("data-theme", "dark");
} else if (themeSetting === "white" || themeSetting === "light") {
docEl.setAttribute("data-theme", themeSetting);
} else {
// auto or unset: check system preference
const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
if (isDark) {
docEl.setAttribute("data-theme", "dark");
} else {
docEl.setAttribute("data-theme", "light");
}
}
}
// Initialize theme and sync listeners
syncTheme();
window.addEventListener("storage", (e) => {
if (e.key === "cli-proxy-theme") {
syncTheme();
}
});
if (window.matchMedia) {
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", syncTheme);
}
setInterval(syncTheme, 2000);
if (!timerInterval) { if (!timerInterval) {
timerInterval = setInterval(updateTimers, 1000); timerInterval = setInterval(updateTimers, 1000);
} }
+79
View File
@@ -0,0 +1,79 @@
package plugin
import (
"encoding/json"
"strings"
)
// ThemeStorageState represents the zustand-persisted theme state from CLIProxyAPI Management Center.
type ThemeStorageState struct {
State struct {
Theme string `json:"theme"`
ResolvedTheme string `json:"resolvedTheme"`
} `json:"state"`
Version int `json:"version"`
}
// ResolveThemeFromStorage extracts theme configuration from the 'cli-proxy-theme' localStorage JSON.
// Returns the user-selected theme setting ("auto", "white", "light", "dark") and the resolved theme ("dark" or "light"/"white").
func ResolveThemeFromStorage(storageJSON string) (theme string, resolved string) {
theme = "auto"
resolved = "light"
s := strings.TrimSpace(storageJSON)
if s == "" {
return theme, resolved
}
// 1. Try zustand persist format
var zustandState ThemeStorageState
if err := json.Unmarshal([]byte(s), &zustandState); err == nil && (zustandState.State.Theme != "" || zustandState.State.ResolvedTheme != "") {
if zustandState.State.Theme != "" {
theme = zustandState.State.Theme
}
if zustandState.State.ResolvedTheme != "" {
resolved = zustandState.State.ResolvedTheme
} else {
switch theme {
case "dark":
resolved = "dark"
case "white", "light":
resolved = "light"
default:
resolved = "light"
}
}
return theme, resolved
}
// 2. Try simple map {"theme": "..."}
var simpleMap map[string]any
if err := json.Unmarshal([]byte(s), &simpleMap); err == nil {
if t, ok := simpleMap["theme"].(string); ok && t != "" {
theme = t
if r, ok2 := simpleMap["resolvedTheme"].(string); ok2 && r != "" {
resolved = r
} else if theme == "dark" {
resolved = "dark"
} else {
resolved = "light"
}
return theme, resolved
}
}
// 3. Fallback for raw string literals like `"dark"` or `dark`
clean := strings.ToLower(strings.Trim(s, "\" \t\r\n"))
switch clean {
case "dark":
return "dark", "dark"
case "white":
return "white", "white"
case "light":
return "light", "light"
case "auto":
return "auto", "light"
}
return theme, resolved
}
+93
View File
@@ -0,0 +1,93 @@
package plugin
import (
"testing"
)
func TestResolveThemeFromStorage(t *testing.T) {
tests := []struct {
name string
storageJSON string
wantTheme string
wantResolved string
}{
{
name: "zustand dark theme",
storageJSON: `{"state":{"theme":"dark","resolvedTheme":"dark"},"version":0}`,
wantTheme: "dark",
wantResolved: "dark",
},
{
name: "zustand white theme",
storageJSON: `{"state":{"theme":"white","resolvedTheme":"light"},"version":0}`,
wantTheme: "white",
wantResolved: "light",
},
{
name: "zustand light theme",
storageJSON: `{"state":{"theme":"light","resolvedTheme":"light"},"version":0}`,
wantTheme: "light",
wantResolved: "light",
},
{
name: "zustand auto theme with resolved dark",
storageJSON: `{"state":{"theme":"auto","resolvedTheme":"dark"},"version":0}`,
wantTheme: "auto",
wantResolved: "dark",
},
{
name: "zustand auto theme with resolved light",
storageJSON: `{"state":{"theme":"auto","resolvedTheme":"light"},"version":0}`,
wantTheme: "auto",
wantResolved: "light",
},
{
name: "simple json dark",
storageJSON: `{"theme":"dark"}`,
wantTheme: "dark",
wantResolved: "dark",
},
{
name: "simple json white",
storageJSON: `{"theme":"white"}`,
wantTheme: "white",
wantResolved: "light",
},
{
name: "raw string dark",
storageJSON: `"dark"`,
wantTheme: "dark",
wantResolved: "dark",
},
{
name: "raw string white",
storageJSON: `white`,
wantTheme: "white",
wantResolved: "white",
},
{
name: "empty string defaults to auto/light",
storageJSON: "",
wantTheme: "auto",
wantResolved: "light",
},
{
name: "invalid json defaults to auto/light",
storageJSON: `{not-valid-json`,
wantTheme: "auto",
wantResolved: "light",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotTheme, gotResolved := ResolveThemeFromStorage(tt.storageJSON)
if gotTheme != tt.wantTheme {
t.Errorf("theme = %q, want %q", gotTheme, tt.wantTheme)
}
if gotResolved != tt.wantResolved {
t.Errorf("resolved = %q, want %q", gotResolved, tt.wantResolved)
}
})
}
}
+21
View File
@@ -225,6 +225,7 @@ type UpstreamCreditsResponse struct {
// UpstreamWindowLimits carries fiveHour and weekly window metrics. // UpstreamWindowLimits carries fiveHour and weekly window metrics.
type UpstreamWindowLimits struct { type UpstreamWindowLimits struct {
Limited *bool `json:"limited,omitempty"`
FiveHour UpstreamWindowLimit `json:"fiveHour"` FiveHour UpstreamWindowLimit `json:"fiveHour"`
Weekly UpstreamWindowLimit `json:"weekly"` Weekly UpstreamWindowLimit `json:"weekly"`
} }
@@ -256,14 +257,33 @@ type UsageWindowLimitData struct {
ResetInSeconds int64 `json:"reset_in_seconds"` 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. // UsageWindowLimitsData contains both windows.
type UsageWindowLimitsData struct { type UsageWindowLimitsData struct {
Monthly UsageWindowLimitData `json:"monthly"`
FiveHour UsageWindowLimitData `json:"five_hour"` FiveHour UsageWindowLimitData `json:"five_hour"`
Weekly UsageWindowLimitData `json:"weekly"` Weekly UsageWindowLimitData `json:"weekly"`
} }
// PlanInfo represents inferred Command Code subscription plan details.
type PlanInfo struct {
Name string `json:"name"`
Code string `json:"code"`
}
// FormattedUsageData is the complete formatted usage payload. // FormattedUsageData is the complete formatted usage payload.
type FormattedUsageData struct { type FormattedUsageData struct {
Plan PlanInfo `json:"plan"`
Credits UsageCreditsData `json:"credits"` Credits UsageCreditsData `json:"credits"`
WindowLimits UsageWindowLimitsData `json:"window_limits"` WindowLimits UsageWindowLimitsData `json:"window_limits"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -272,6 +292,7 @@ type FormattedUsageData struct {
// FormattedUsageResponse is returned by GET /plugins/commandcode/usage and POST /plugins/commandcode/usage. // FormattedUsageResponse is returned by GET /plugins/commandcode/usage and POST /plugins/commandcode/usage.
type FormattedUsageResponse struct { type FormattedUsageResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Plan PlanInfo `json:"plan"`
Data FormattedUsageData `json:"data"` Data FormattedUsageData `json:"data"`
Credits UsageCreditsData `json:"credits"` Credits UsageCreditsData `json:"credits"`
WindowLimits UsageWindowLimitsData `json:"window_limits"` WindowLimits UsageWindowLimitsData `json:"window_limits"`
+120 -4
View File
@@ -42,8 +42,9 @@ func SetDefaultHTTPClient(client HTTPDoer) {
} }
} }
// FetchCreditsRaw fetches raw upstream credit data via host.http.do or net/http fallback. // fetchUpstream performs a GET on an internal Command Code endpoint, reusing
func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCallbackID string) ([]byte, int, error) { // 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) cleanToken := ExtractSessionToken(sessionToken)
if cleanToken == "" { if cleanToken == "" {
return nil, http.StatusBadRequest, errors.New("missing session_token: please provide a valid Command Code session token") 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 == "" { if apiBase == "" {
apiBase = DefaultAPIBase 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) cookieValue := FormatSessionCookie(cleanToken)
// 1. Try host.http.do if hostCaller is configured // 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 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. // 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 { if len(raw) == 0 {
return nil, errors.New("empty response body from upstream") return nil, errors.New("empty response body from upstream")
} }
@@ -149,9 +161,14 @@ func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, er
// Format window limits // Format window limits
windowLimitsData := formatWindowLimits(upstream.WindowLimits, now) windowLimitsData := formatWindowLimits(upstream.WindowLimits, now)
windowLimitsData.Monthly = formatMonthlyWindow(upstream.Credits, summary, now)
// Inferred subscription plan
planInfo := PlanFromWindowLimits(upstream.WindowLimits.FiveHour.Cap, upstream.WindowLimits.Weekly.Cap, upstream.WindowLimits.Limited)
nowRFC := now.Format(time.RFC3339) nowRFC := now.Format(time.RFC3339)
data := FormattedUsageData{ data := FormattedUsageData{
Plan: planInfo,
Credits: creditsData, Credits: creditsData,
WindowLimits: windowLimitsData, WindowLimits: windowLimitsData,
UpdatedAt: nowRFC, UpdatedAt: nowRFC,
@@ -159,6 +176,7 @@ func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, er
return &FormattedUsageResponse{ return &FormattedUsageResponse{
OK: true, OK: true,
Plan: planInfo,
Data: data, Data: data,
Credits: creditsData, Credits: creditsData,
WindowLimits: windowLimitsData, WindowLimits: windowLimitsData,
@@ -166,6 +184,69 @@ func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, er
}, nil }, nil
} }
// PlanFromWindowLimits infers the Command Code subscription plan based on window limit caps.
//
// Rules:
// - windowLimits.limited == false -> Provider plan (pay-as-you-go)
// - 5h cap=14 && weekly cap=35 -> GOAT plan
// - 5h cap=16 && weekly cap=40 -> Pro plan
// - 5h cap=90 && weekly cap=180 -> Max 20× plan
// - 5h cap=45 && weekly cap=90 -> Max 10× plan
// - 5h cap=3 && weekly cap=6 -> Go plan
// - Otherwise -> Unknown
func PlanFromWindowLimits(fiveHourCap, weeklyCap float64, limited *bool) PlanInfo {
if limited != nil && !*limited {
return PlanInfo{
Name: "Provider",
Code: "provider",
}
}
match := func(capVal, target float64) bool {
return math.Abs(capVal-target) < 0.01
}
if match(fiveHourCap, 14) && match(weeklyCap, 35) {
return PlanInfo{
Name: "GOAT",
Code: "goat",
}
}
if match(fiveHourCap, 16) && match(weeklyCap, 40) {
return PlanInfo{
Name: "Pro",
Code: "pro",
}
}
if match(fiveHourCap, 90) && match(weeklyCap, 180) {
return PlanInfo{
Name: "Max 20×",
Code: "max_20x",
}
}
if match(fiveHourCap, 45) && match(weeklyCap, 90) {
return PlanInfo{
Name: "Max 10×",
Code: "max_10x",
}
}
if match(fiveHourCap, 3) && match(weeklyCap, 6) {
return PlanInfo{
Name: "Go",
Code: "go",
}
}
return PlanInfo{
Name: "Unknown",
Code: "unknown",
}
}
func formatCredits(credits map[string]any) UsageCreditsData { func formatCredits(credits map[string]any) UsageCreditsData {
data := UsageCreditsData{ data := UsageCreditsData{
Details: credits, Details: credits,
@@ -188,6 +269,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 { func formatSingleWindow(w UpstreamWindowLimit, now time.Time) UsageWindowLimitData {
var remaining float64 var remaining float64
var percentage float64 var percentage float64
+158 -2
View File
@@ -3,6 +3,7 @@ package plugin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -81,7 +82,44 @@ func TestParseAndFormatUsage(t *testing.T) {
}`) }`)
now := time.Date(2025, 3, 4, 12, 0, 0, 0, time.UTC) 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 { if err != nil {
t.Fatalf("ParseAndFormatUsage error: %v", err) t.Fatalf("ParseAndFormatUsage error: %v", err)
} }
@@ -136,6 +174,27 @@ func TestParseAndFormatUsage(t *testing.T) {
if weekly.ResetAt != "2025-03-10T12:00:00Z" { if weekly.ResetAt != "2025-03-10T12:00:00Z" {
t.Errorf("Weekly ResetAt = %v, want 2025-03-10T12:00:00Z", weekly.ResetAt) t.Errorf("Weekly ResetAt = %v, want 2025-03-10T12:00:00Z", weekly.ResetAt)
} }
// Verify inferred plan (cap 25 / 100 is unknown)
if usage.Plan.Name != "Unknown" || usage.Plan.Code != "unknown" {
t.Errorf("Plan = %+v, want Unknown", usage.Plan)
}
// Verify plan inference for GOAT
rawGOAT := []byte(`{
"credits": {"monthlyCredits": 100},
"windowLimits": {
"fiveHour": {"used": 2, "cap": 14},
"weekly": {"used": 10, "cap": 35}
}
}`)
usageGOAT, errGOAT := ParseAndFormatUsage(rawGOAT, nil, now)
if errGOAT != nil {
t.Fatalf("ParseAndFormatUsage GOAT error: %v", errGOAT)
}
if usageGOAT.Plan.Name != "GOAT" || usageGOAT.Plan.Code != "goat" {
t.Errorf("GOAT Plan = %+v, want name=GOAT code=goat", usageGOAT.Plan)
}
} }
func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) { func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) {
@@ -174,7 +233,7 @@ func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) {
t.Fatal("expected non-empty body") t.Fatal("expected non-empty body")
} }
usage, errParse := ParseAndFormatUsage(body, time.Time{}) usage, errParse := ParseAndFormatUsage(body, nil, time.Time{})
if errParse != nil { if errParse != nil {
t.Fatalf("ParseAndFormatUsage error: %v", errParse) t.Fatalf("ParseAndFormatUsage error: %v", errParse)
} }
@@ -220,3 +279,100 @@ func TestFetchCreditsRaw_HostCaller(t *testing.T) {
t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload)) t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload))
} }
} }
func TestPlanFromWindowLimits(t *testing.T) {
trueVal := true
falseVal := false
tests := []struct {
name string
fiveHourCap float64
weeklyCap float64
limited *bool
wantName string
wantCode string
}{
{
name: "GOAT plan",
fiveHourCap: 14,
weeklyCap: 35,
limited: &trueVal,
wantName: "GOAT",
wantCode: "goat",
},
{
name: "Pro plan",
fiveHourCap: 16,
weeklyCap: 40,
limited: nil,
wantName: "Pro",
wantCode: "pro",
},
{
name: "Max 20x plan",
fiveHourCap: 90,
weeklyCap: 180,
limited: &trueVal,
wantName: "Max 20×",
wantCode: "max_20x",
},
{
name: "Max 10x plan",
fiveHourCap: 45,
weeklyCap: 90,
limited: nil,
wantName: "Max 10×",
wantCode: "max_10x",
},
{
name: "Go plan",
fiveHourCap: 3,
weeklyCap: 6,
limited: nil,
wantName: "Go",
wantCode: "go",
},
{
name: "Provider pay-as-you-go plan",
fiveHourCap: 0,
weeklyCap: 0,
limited: &falseVal,
wantName: "Provider",
wantCode: "provider",
},
{
name: "Provider plan with caps set but limited=false",
fiveHourCap: 14,
weeklyCap: 35,
limited: &falseVal,
wantName: "Provider",
wantCode: "provider",
},
{
name: "Float tolerance test",
fiveHourCap: 13.999,
weeklyCap: 35.001,
limited: nil,
wantName: "GOAT",
wantCode: "goat",
},
{
name: "Unknown caps",
fiveHourCap: 10,
weeklyCap: 20,
limited: nil,
wantName: "Unknown",
wantCode: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PlanFromWindowLimits(tt.fiveHourCap, tt.weeklyCap, tt.limited)
if got.Name != tt.wantName || got.Code != tt.wantCode {
t.Errorf("PlanFromWindowLimits(%v, %v, %v) = %+v, want name=%q code=%q",
tt.fiveHourCap, tt.weeklyCap, tt.limited, got, tt.wantName, tt.wantCode)
}
})
}
}