feat: add OpenCode Go usage query with aggregated /all endpoint, bump v0.3.0

- New provider: OpenCode Go (GET https://opencode.ai/zen/go/v1/usage, Bearer auth)
- Extract shared transport doUpstreamRequest (host.http.do first, net/http fallback)
- New management routes: GET/POST /plugins/commandcode/opencode/usage, GET/POST /plugins/commandcode/all
- /all aggregates both providers sequentially with partial-failure semantics
  (>=1 success -> 200, all-local-missing -> 400, all-upstream-failure -> 502)
- QuotaCard UI: tabs (Command Code / OpenCode Go / All), OpenCode window cards,
  version badge v0.3.0, OpenCode API key test override in settings drawer
- Config: opencode_api_key / opencode_api_base (ConfigFields 2 -> 4)
- Tests: route-order regression, /all partial failure & misclassification guards,
  ParseOpenCodeUsage edge cases, host/http transport paths
This commit is contained in:
2026-09-18 09:38:40 +08:00
parent 43c09885e5
commit 61c50280b1
10 changed files with 1903 additions and 74 deletions
+106 -13
View File
@@ -14,7 +14,8 @@ import (
)
const (
DefaultAPIBase = "https://api.commandcode.ai"
DefaultAPIBase = "https://api.commandcode.ai"
DefaultOpenCodeAPIBase = "https://opencode.ai/zen/go/v1"
)
// HTTPDoer abstracts HTTP requests for testing and fallback.
@@ -56,16 +57,24 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
cookieValue := FormatSessionCookie(cleanToken)
headers := map[string][]string{
"Cookie": {cookieValue},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// doUpstreamRequest is the shared transport layer: it tries the host.http.do
// bridge first (when a host caller is registered) and falls back to net/http.
// Request semantics (method, URL, headers) are fully controlled by the caller.
func doUpstreamRequest(ctx context.Context, method, url string, headers map[string][]string, hostCallbackID string) ([]byte, int, error) {
// 1. Try host.http.do if hostCaller is configured
if hostCaller != nil {
reqPayload := HostHTTPRequest{
Method: http.MethodGet,
URL: url,
Headers: map[string][]string{
"Cookie": {cookieValue},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
},
Method: method,
URL: url,
Headers: headers,
HostCallbackID: hostCallbackID,
}
rawReq, errMarshal := json.Marshal(reqPayload)
@@ -97,13 +106,15 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
}
// 2. Fallback to Go net/http client
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
httpReq, errNew := http.NewRequestWithContext(ctx, method, url, nil)
if errNew != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("create HTTP request: %w", errNew)
}
httpReq.Header.Set("Cookie", cookieValue)
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion))
for key, values := range headers {
for _, value := range values {
httpReq.Header.Add(key, value)
}
}
res, errDo := defaultHTTPClient.Do(httpReq)
if errDo != nil {
@@ -131,6 +142,88 @@ func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hos
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
}
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, http.StatusBadRequest, errors.New("missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request")
}
if apiBase == "" {
apiBase = DefaultOpenCodeAPIBase
}
url := strings.TrimRight(apiBase, "/") + "/usage"
headers := map[string][]string{
"Authorization": {"Bearer " + apiKey},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// ParseOpenCodeUsage parses OpenCode Go usage JSON into the formatted response.
// Unknown status values are tolerated; a resetsAt that fails to parse is not
// fatal (ResetAt stays empty and ResetInSeconds stays 0).
func ParseOpenCodeUsage(raw []byte, now time.Time) (*OpenCodeFormattedUsageResponse, error) {
if len(raw) == 0 {
return nil, errors.New("empty response body from upstream")
}
var upstream OpenCodeUsageResponse
if err := json.Unmarshal(raw, &upstream); err != nil {
return nil, fmt.Errorf("unmarshal opencode usage response: %w", err)
}
if now.IsZero() {
now = time.Now().UTC()
}
return &OpenCodeFormattedUsageResponse{
OK: true,
Provider: "opencode_go",
Windows: OpenCodeFormattedWindows{
Rolling: formatOpenCodeWindow(upstream.Usage.Rolling, now),
Weekly: formatOpenCodeWindow(upstream.Usage.Weekly, now),
Monthly: formatOpenCodeWindow(upstream.Usage.Monthly, now),
},
UpdatedAt: now.Format(time.RFC3339),
}, nil
}
// formatOpenCodeWindow formats a single OpenCode Go usage window.
func formatOpenCodeWindow(w OpenCodeUsageWindow, now time.Time) OpenCodeFormattedWindow {
percent := clampOpenCodePercent(w.Percent)
out := OpenCodeFormattedWindow{
Status: w.Status,
Percent: percent,
Exceeded: percent >= 100 || w.Status == "exceeded",
}
if w.ResetsAt != "" {
if t, err := time.Parse(time.RFC3339, w.ResetsAt); err == nil {
out.ResetAt = t.UTC().Format(time.RFC3339)
if diff := t.UTC().Sub(now); diff > 0 {
out.ResetInSeconds = int64(diff.Seconds())
}
}
// Parse failure is not fatal: ResetAt stays empty, ResetInSeconds stays 0.
}
return out
}
// clampOpenCodePercent clamps a percentage to [0, 100] with 2-decimal rounding.
func clampOpenCodePercent(p float64) float64 {
if p < 0 {
p = 0
}
if p > 100 {
p = 100
}
return math.Round(p*100) / 100
}
// ParseAndFormatUsage parses upstream credits JSON into structured usage metrics.
// 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) {
@@ -299,7 +392,7 @@ func formatMonthlyWindow(credits map[string]any, summary *UpstreamUsageSummaryRe
if out.Remaining < 0 {
out.Remaining = 0
}
out.ResetAt = "" // 账单周期重置时间上游未提供
out.ResetAt = "" // 账单周期重置时间上游未提供
out.ResetInSeconds = 0
return out
}