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
+302 -3
View File
@@ -3,6 +3,7 @@ package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
@@ -22,12 +23,32 @@ func RegisterManagement() (ManagementRegistrationResponse, error) {
Path: "/plugins/commandcode/usage",
Description: "Query Command Code credits and window limits usage with custom session_token",
},
{
Method: http.MethodGet,
Path: "/plugins/commandcode/opencode/usage",
Description: "Query OpenCode Go usage windows (rolling/weekly/monthly)",
},
{
Method: http.MethodPost,
Path: "/plugins/commandcode/opencode/usage",
Description: "Query OpenCode Go usage windows with custom opencode_api_key",
},
{
Method: http.MethodGet,
Path: "/plugins/commandcode/all",
Description: "Query both Command Code and OpenCode Go usage (aggregated, partial failures reported in errors map)",
},
{
Method: http.MethodPost,
Path: "/plugins/commandcode/all",
Description: "Query both providers with custom credentials in request body",
},
},
Resources: []ResourceRoute{
{
Path: "/quota",
Menu: "Command Code 配额",
Description: "Command Code 用量与限额卡片",
Menu: "用量配额",
Description: "Command Code + OpenCode Go 用量与限额卡片",
},
},
}, nil
@@ -49,7 +70,42 @@ func HandleManagement(ctx context.Context, req ManagementRequest, cfg *PluginCon
}, nil
}
// 2. Serve Usage API (GET / POST)
// 2. OpenCode Go usage API — MUST be matched before the generic /usage
// suffix match below, otherwise "/plugins/commandcode/opencode/usage"
// would be swallowed by the Command Code handler.
if strings.HasSuffix(path, "/plugins/commandcode/opencode/usage") {
switch method {
case http.MethodGet, http.MethodPost:
return handleOpenCodeUsage(ctx, req, cfg)
default:
return ManagementResponse{
StatusCode: http.StatusMethodNotAllowed,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
}, nil
}
}
// 3. Aggregated usage API (both providers) — does not end with "/usage",
// but registered before the generic match for clarity and future safety.
if strings.HasSuffix(path, "/plugins/commandcode/all") {
switch method {
case http.MethodGet, http.MethodPost:
return handleAllUsage(ctx, req, cfg)
default:
return ManagementResponse{
StatusCode: http.StatusMethodNotAllowed,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
}, nil
}
}
// 4. Command Code usage API (GET / POST) — generic suffix match kept as-is.
if strings.HasSuffix(path, "/plugins/commandcode/usage") || strings.HasSuffix(path, "/usage") {
switch method {
case http.MethodGet:
@@ -215,3 +271,246 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
Body: resBytes,
}, nil
}
// handleOpenCodeUsage serves GET/POST /plugins/commandcode/opencode/usage.
// Credentials can be overridden via POST body only (opencode_api_key / api_key);
// GET queries are read-only against the plugin config — query parameter
// overrides are intentionally not supported to keep secrets out of URLs.
func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
apiKey := ""
apiBase := ""
if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 {
var body struct {
OpenCodeAPIKey string `json:"opencode_api_key"`
APIKey string `json:"api_key"`
OpenCodeAPIBase string `json:"opencode_api_base"`
}
_ = json.Unmarshal(req.Body, &body)
apiKey = body.OpenCodeAPIKey
if apiKey == "" {
apiKey = body.APIKey
}
apiBase = body.OpenCodeAPIBase
}
// Fallback to plugin config
if apiKey == "" && cfg != nil {
apiKey = cfg.GetOpenCodeAPIKey()
}
if apiBase == "" && cfg != nil {
apiBase = cfg.GetOpenCodeAPIBase()
}
return handleOpenCodeUsageWithKey(ctx, apiBase, apiKey, req.HostCallbackID)
}
// handleAllUsage serves GET/POST /plugins/commandcode/all: it queries both
// providers sequentially (no goroutines — the host.http.do bridge's host-side
// concurrency safety cannot be verified and shared maps would race under -race).
// Partial failure: OK=true as long as at least one provider succeeds; failures
// land in the Errors map and successful fields are omitted when absent.
// HTTP status: any success → 200; all failed due to missing local credentials → 400;
// all failed due to upstream errors → 502.
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
sessionToken := ""
opencodeKey := ""
if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 {
var body struct {
SessionToken string `json:"session_token"`
OpencodeAPIKey string `json:"opencode_api_key"`
}
_ = json.Unmarshal(req.Body, &body)
sessionToken = body.SessionToken
opencodeKey = body.OpencodeAPIKey
}
// Fallback to plugin config
if sessionToken == "" && cfg != nil {
sessionToken = cfg.GetSessionToken()
}
if opencodeKey == "" && cfg != nil {
opencodeKey = cfg.GetOpenCodeAPIKey()
}
apiBase := ""
if cfg != nil {
apiBase = cfg.GetAPIBase()
}
ocAPIBase := ""
if cfg != nil {
ocAPIBase = cfg.GetOpenCodeAPIBase()
}
now := time.Now().UTC()
resp := AllUsageResponse{OK: false, UpdatedAt: now.Format(time.RFC3339)}
errs := make(map[string]string)
localMissing := 0
upstreamFailed := 0
succeeded := 0
// Provider 1: Command Code (reuses executeUsageQuery).
ccResp, _ := executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
if ccResp.StatusCode == http.StatusOK {
resp.CommandCode = ccResp.Body
succeeded++
} else {
ccErr := extractErrorResponseMessage(ccResp.Body)
errs["commandcode"] = ccErr
if isLocalCredentialError(ccErr) {
localMissing++
} else {
upstreamFailed++
}
}
// Provider 2: OpenCode Go (same classification via isLocalCredentialError,
// not by HTTP 400 alone: upstream 4xx may be passed through and must not
// be misclassified as a local configuration problem).
if strings.TrimSpace(opencodeKey) != "" {
ocResp, _ := handleOpenCodeUsageWithKey(ctx, ocAPIBase, opencodeKey, req.HostCallbackID)
if ocResp.StatusCode == http.StatusOK {
resp.OpenCode = ocResp.Body
succeeded++
} else {
ocErr := extractErrorResponseMessage(ocResp.Body)
errs["opencode"] = ocErr
if isLocalCredentialError(ocErr) {
localMissing++
} else {
upstreamFailed++
}
}
} else {
errs["opencode"] = "missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request body"
localMissing++
}
if len(errs) > 0 {
resp.Errors = errs
}
resp.OK = succeeded > 0
statusCode := http.StatusOK
if !resp.OK {
if upstreamFailed == 0 && localMissing == len(errs) {
statusCode = http.StatusBadRequest
} else {
statusCode = http.StatusBadGateway
}
}
resBytes, _ := json.Marshal(resp)
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
// isLocalCredentialError reports whether an /all provider error is a local
// configuration problem (missing credential in plugin config), as opposed to
// an upstream failure. Local-credential errors carry fixed message prefixes;
// upstream 4xx/5xx never match them, so the /all 400-vs-502 classification
// does not rely on the HTTP status alone.
func isLocalCredentialError(msg string) bool {
for _, prefix := range []string{
"session_token is required",
"opencode_api_key is required",
} {
if strings.HasPrefix(msg, prefix) {
return true
}
}
return false
}
// handleOpenCodeUsageWithKey runs the OpenCode usage query with an explicit
// credential, shared by handleOpenCodeUsage and handleAllUsage.
func handleOpenCodeUsageWithKey(ctx context.Context, apiBase, apiKey, hostCallbackID string) (ManagementResponse, error) {
if strings.TrimSpace(apiKey) == "" {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "opencode_api_key is required. Configure opencode_api_key in plugin config or pass it in the request body",
})
return ManagementResponse{
StatusCode: http.StatusBadRequest,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, apiKey, hostCallbackID)
if errFetch != nil {
errMsg := fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error())
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"status_code": statusCode,
"error": errMsg,
})
if statusCode == 0 || statusCode == http.StatusOK {
statusCode = http.StatusBadGateway
}
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
if statusCode != http.StatusOK {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"status_code": statusCode,
"error": fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode),
})
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
usage, errParse := ParseOpenCodeUsage(raw, time.Now().UTC())
if errParse != nil {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "failed to parse opencode upstream usage: " + errParse.Error(),
})
return ManagementResponse{
StatusCode: http.StatusBadGateway,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
resBytes, _ := json.Marshal(usage)
return ManagementResponse{
StatusCode: http.StatusOK,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
// extractErrorResponseMessage pulls the "error" field out of a JSON error body.
func extractErrorResponseMessage(body []byte) string {
var parsed struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" {
return parsed.Error
}
return "unknown error"
}