mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 03:32:50 +08:00
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:
+302
-3
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user