diff --git a/README.md b/README.md index 62c8c3c..e9548ba 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ - 支持在 `config.yaml` 配置或在配额页面上直接输入。 - 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。 4. **精确用量与双滑动窗口限额解析**: - - 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。 + - 上游接口(v0.5.0+):配置 `commandcode_api_key`(Provider API key,长期凭据)时走 `GET https://api.commandcode.ai/alpha/billing/credits` 与 `/alpha/usage/summary`(Bearer 认证);否则回退 session cookie 查 `/internal/billing/credits`。 - 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。 - 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。 5. **嵌入式纯单文件 QuotaCard 资源页**: @@ -132,7 +132,8 @@ plugins: commandcode: enabled: true priority: 1 - session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串 + session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # v0.4.5 前唯一凭据;v0.5.0 起为可选回退 + commandcode_api_key: "user_YOUR_COMMANDCODE_PROVIDER_KEY" # v0.5.0+ 推荐:非空则用量查询走 /alpha 端点(Bearer),无需 session cookie api_base: "https://api.commandcode.ai" # 可选,默认为官方接口 opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+) # v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口 diff --git a/plugin/management.go b/plugin/management.go index 8a8dba8..cce31f3 100644 --- a/plugin/management.go +++ b/plugin/management.go @@ -157,14 +157,23 @@ func handleGetUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi apiBase = cfg.GetAPIBase() } - return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID) + // commandcode_api_key is NOT overridable via query parameters (same + // secrets-out-of-URLs policy as the OpenCode handler): the plugin + // config is the only credential source on GET. + apiKey := "" + if cfg != nil { + apiKey = cfg.GetCommandCodeAPIKey() + } + + return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID) } func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) { var body struct { - SessionToken string `json:"session_token"` - Token string `json:"token"` - APIBase string `json:"api_base"` + SessionToken string `json:"session_token"` + Token string `json:"token"` + APIBase string `json:"api_base"` + CommandCodeAPIKey string `json:"commandcode_api_key"` } if len(req.Body) > 0 { @@ -176,6 +185,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf sessionToken = body.Token } apiBase := body.APIBase + apiKey := body.CommandCodeAPIKey // Fallback to plugin config if body didn't specify if sessionToken == "" && cfg != nil { @@ -184,12 +194,22 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf if apiBase == "" && cfg != nil { apiBase = cfg.GetAPIBase() } + if apiKey == "" && cfg != nil { + apiKey = cfg.GetCommandCodeAPIKey() + } - return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID) + return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID) } -func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackID string) (ManagementResponse, error) { - if strings.TrimSpace(sessionToken) == "" { +func executeUsageQuery(ctx context.Context, apiBase, apiKey, sessionToken, hostCallbackID string) (ManagementResponse, error) { + apiKey = strings.TrimSpace(apiKey) + + // Credential priority: commandcode_api_key non-empty → /alpha + Bearer + // (Provider API key, no cookie); otherwise session_token → /internal + // + Cookie (legacy fallback). Neither present → 400 with the + // "session_token is required" prefix (isLocalCredentialError in the + // /all aggregate depends on this message). + if strings.TrimSpace(sessionToken) == "" && apiKey == "" { resBytes, _ := json.Marshal(map[string]any{ "ok": false, "error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request", @@ -203,7 +223,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI }, nil } - raw, statusCode, errFetch := FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID) + var raw []byte + var statusCode int + var errFetch error + if apiKey != "" { + raw, statusCode, errFetch = FetchCommandCodeCreditsAlphaRaw(ctx, apiBase, apiKey, hostCallbackID) + } else { + raw, statusCode, errFetch = FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID) + } if errFetch != nil { resBytes, _ := json.Marshal(map[string]any{ "ok": false, @@ -223,12 +250,19 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI } if statusCode != http.StatusOK { - resBytes, _ := json.Marshal(map[string]any{ + payload := map[string]any{ "ok": false, "status_code": statusCode, - "error": "upstream returned non-200 status", - "body": string(raw), - }) + } + if apiKey != "" { + // Alpha path: do NOT echo the upstream body; point at the + // configured Provider API key instead. + payload["error"] = fmt.Sprintf("commandcode upstream returned %d: check commandcode_api_key", statusCode) + } else { + payload["error"] = "upstream returned non-200 status" + payload["body"] = string(raw) + } + resBytes, _ := json.Marshal(payload) return ManagementResponse{ StatusCode: statusCode, Headers: map[string][]string{ @@ -240,7 +274,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI // 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 { + if apiKey != "" { + if sumRaw, sumStatus, sumErr := FetchCommandCodeUsageSummaryAlphaRaw(ctx, apiBase, apiKey, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK { + var parsed UpstreamUsageSummaryResponse + if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 { + summary = &parsed + } + } + } else 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 @@ -369,16 +410,19 @@ func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *Plugin // all failed due to upstream errors → 502. func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) { sessionToken := "" + commandcodeAPIKey := "" opencodeKeys := []string{} if req.Method == http.MethodPost && len(req.Body) > 0 { var body struct { - SessionToken string `json:"session_token"` - OpencodeAPIKeys []string `json:"opencode_api_keys"` - OpencodeAPIKey string `json:"opencode_api_key"` + SessionToken string `json:"session_token"` + CommandCodeAPIKey string `json:"commandcode_api_key"` + OpencodeAPIKeys []string `json:"opencode_api_keys"` + OpencodeAPIKey string `json:"opencode_api_key"` } _ = json.Unmarshal(req.Body, &body) sessionToken = body.SessionToken + commandcodeAPIKey = body.CommandCodeAPIKey opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys) if len(opencodeKeys) == 0 { if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" { @@ -391,6 +435,9 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi if sessionToken == "" && cfg != nil { sessionToken = cfg.GetSessionToken() } + if commandcodeAPIKey == "" && cfg != nil { + commandcodeAPIKey = cfg.GetCommandCodeAPIKey() + } if len(opencodeKeys) == 0 && cfg != nil { opencodeKeys = cfg.GetOpenCodeAPIKeys() } @@ -411,7 +458,7 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi succeeded := 0 // Provider 1: Command Code (reuses executeUsageQuery). - ccResp, _ := executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID) + ccResp, _ := executeUsageQuery(ctx, apiBase, commandcodeAPIKey, sessionToken, req.HostCallbackID) if ccResp.StatusCode == http.StatusOK { resp.CommandCode = ccResp.Body succeeded++ diff --git a/plugin/management_test.go b/plugin/management_test.go index df3333f..d39392f 100644 --- a/plugin/management_test.go +++ b/plugin/management_test.go @@ -75,8 +75,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) { if !strings.Contains(bodyStr, "用量配额") { t.Errorf("Body does not contain expected menu text 用量配额") } - if !strings.Contains(bodyStr, "v0.4.5") { - t.Errorf("Body does not contain version badge v0.4.5") + if !strings.Contains(bodyStr, "v0.5.0") { + t.Errorf("Body does not contain version badge v0.5.0") } } } @@ -820,3 +820,347 @@ func TestHandleManagement_UnknownPath(t *testing.T) { t.Fatalf("StatusCode = %d, want 404", resp.StatusCode) } } + +// ---- v0.5.0: commandcode_api_key → /alpha + Bearer path ---- + +// mockAlphaCreditsJSON omits opensourceMonthlyCredits, matching the real +// /alpha/billing/credits payload shape (field difference vs /internal). +const mockAlphaCreditsJSON = `{"credits":{"monthlyCredits":555},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}` + +// newAlphaTestServer: /alpha/billing/credits and /alpha/usage/summary both +// assert Bearer auth and the absence of a Cookie header; every other path +// fails the test (regression guard against falling back to /internal). +func newAlphaTestServer(t *testing.T, wantKey string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+wantKey { + t.Errorf("upstream Authorization = %q, want Bearer %s", got, wantKey) + } + if got := r.Header.Get("Cookie"); got != "" { + t.Errorf("upstream Cookie = %q, want none on the /alpha path", got) + } + switch r.URL.Path { + case "/alpha/billing/credits": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(mockAlphaCreditsJSON)) + case "/alpha/usage/summary": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`)) + default: + t.Errorf("unexpected upstream request: %s %s (internal path must not be hit when commandcode_api_key is set)", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) +} + +// GET /usage with commandcode_api_key configured → both upstream calls hit +// /alpha/* with Bearer auth and no Cookie; response parses with the alpha +// payload (total = monthly when opensource field is absent) and the monthly +// window is derived from the /alpha summary. +func TestHandleManagement_GetUsage_AlphaKey(t *testing.T) { + ts := newAlphaTestServer(t, "user_cfg-key") + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + cfg := &PluginConfig{ + CommandCodeAPIKey: "user_cfg-key", + APIBase: ts.URL, + } + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/usage", + } + resp, err := HandleManagement(context.Background(), req, cfg) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body)) + } + + var usage FormattedUsageResponse + if err := json.Unmarshal(resp.Body, &usage); err != nil { + t.Fatalf("unmarshal body error: %v", err) + } + if !usage.OK { + t.Fatal("expected OK=true") + } + if usage.Credits.MonthlyCredits != 555 || usage.Credits.TotalCredits != 555 { + t.Errorf("credits = %+v, want monthly=555 total=555 (opensource absent in alpha payload)", usage.Credits) + } +} + +// POST /usage: body commandcode_api_key overrides the configured key (the +// config key gets a 401 from the mock, so a 200 proves the body key won). +func TestHandleManagement_PostUsage_AlphaKeyBodyOverridesConfig(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Header.Get("Authorization") { + case "Bearer user_body-key": + switch r.URL.Path { + case "/alpha/billing/credits": + _, _ = w.Write([]byte(mockAlphaCreditsJSON)) + case "/alpha/usage/summary": + _, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + } + default: + t.Errorf("upstream got Authorization %q — config key must lose to the POST body key", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusUnauthorized) + } + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + reqBody, _ := json.Marshal(map[string]string{ + "commandcode_api_key": "user_body-key", + "api_base": ts.URL, + }) + req := ManagementRequest{ + Method: http.MethodPost, + Path: "/plugins/commandcode/usage", + Body: reqBody, + } + cfg := &PluginConfig{CommandCodeAPIKey: "user_cfg-must-lose", APIBase: ts.URL} + resp, err := HandleManagement(context.Background(), req, cfg) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200 (body key overrides config), body=%s", resp.StatusCode, string(resp.Body)) + } + var usage FormattedUsageResponse + if err := json.Unmarshal(resp.Body, &usage); err != nil || !usage.OK { + t.Errorf("unexpected response: err=%v usage=%+v", err, usage) + } +} + +// Regression: without commandcode_api_key the legacy /internal + Cookie path +// is preserved; the /alpha endpoints must never be requested. +func TestHandleManagement_Usage_FallbackToInternalCookie(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/alpha/") { + t.Errorf("unexpected /alpha request %s — must stay on /internal without commandcode_api_key", r.URL.Path) + } + switch r.URL.Path { + case "/internal/billing/credits": + if !strings.Contains(r.Header.Get("Cookie"), "legacy-cookie-token") { + t.Errorf("Cookie = %q, want the session token cookie", r.Header.Get("Cookie")) + } + _, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 321},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`)) + case "/internal/usage/summary": + _, _ = w.Write([]byte(`{"totalMonthlyCredits": 11}`)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + cfg := &PluginConfig{SessionToken: "legacy-cookie-token", APIBase: ts.URL} + + t.Run("GET falls back to internal", func(t *testing.T) { + req := ManagementRequest{Method: http.MethodGet, Path: "/plugins/commandcode/usage"} + resp, err := HandleManagement(context.Background(), req, cfg) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body)) + } + }) + + t.Run("POST falls back to internal", func(t *testing.T) { + reqBody, _ := json.Marshal(map[string]string{"session_token": "legacy-cookie-token", "api_base": ts.URL}) + req := ManagementRequest{Method: http.MethodPost, Path: "/plugins/commandcode/usage", Body: reqBody} + resp, err := HandleManagement(context.Background(), req, &PluginConfig{}) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body)) + } + }) +} + +// GET must NOT honor a commandcode_api_key query parameter (same +// secrets-out-of-URLs policy as the OpenCode handler): the config's +// session_token path is used and the query-provided key is ignored. +func TestHandleManagement_GetUsage_NoQueryKeyOverride(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/alpha/billing/credits" || r.URL.Path == "/alpha/usage/summary" { + t.Errorf("unexpected /alpha request %s — query override of commandcode_api_key is not supported", r.URL.Path) + } + if r.URL.Path != "/internal/billing/credits" { + return + } + if r.Header.Get("Cookie") == "" { + t.Errorf("Cookie = %q, want the configured session token cookie", r.Header.Get("Cookie")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 77},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`)) + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + cfg := &PluginConfig{SessionToken: "configured-cookie-token", APIBase: ts.URL} + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/usage", + Query: map[string][]string{ + "commandcode_api_key": {"user_query-must-be-ignored"}, + }, + } + resp, err := HandleManagement(context.Background(), req, cfg) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body)) + } +} + +// Alpha upstream non-200 → statusCode passed through, message points at +// commandcode_api_key, and the upstream body is NOT echoed (unlike the +// internal path). +func TestHandleManagement_Usage_AlphaUpstreamNon200(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"upstream secret detail xyzzy"}`)) + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + cfg := &PluginConfig{CommandCodeAPIKey: "user_bad-key", APIBase: ts.URL} + resp, err := HandleManagement(context.Background(), ManagementRequest{ + Method: http.MethodGet, + Path: "/plugins/commandcode/usage", + }, cfg) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("StatusCode = %d, want 401 (upstream status passed through)", resp.StatusCode) + } + + var errResp struct { + OK bool `json:"ok"` + StatusCode int `json:"status_code"` + Error string `json:"error"` + Body string `json:"body"` + } + if err := json.Unmarshal(resp.Body, &errResp); err != nil { + t.Fatalf("unmarshal error body: %v", err) + } + if errResp.OK || errResp.StatusCode != http.StatusUnauthorized { + t.Errorf("error payload = %+v, want ok=false status_code=401", errResp) + } + if !strings.Contains(errResp.Error, "commandcode upstream returned 401: check commandcode_api_key") { + t.Errorf("error = %q, want the commandcode_api_key hint message", errResp.Error) + } + if strings.Contains(string(resp.Body), "upstream secret detail") { + t.Errorf("alpha branch must not echo the upstream body, got: %s", string(resp.Body)) + } +} + +// Neither credential → 400 with the preserved "session_token is required" +// prefix (isLocalCredentialError in /all depends on it). +func TestHandleManagement_Usage_NoCredentialsStillSessionTokenMessage(t *testing.T) { + SetHostCaller(nil) + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + resp, err := HandleManagement(context.Background(), ManagementRequest{ + Method: http.MethodGet, + Path: "/plugins/commandcode/usage", + }, &PluginConfig{}) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("StatusCode = %d, want 400, body=%s", resp.StatusCode, string(resp.Body)) + } + if msg := extractErrorResponseMessage(resp.Body); !strings.HasPrefix(msg, "session_token is required") { + t.Errorf("error = %q, want the preserved 'session_token is required' prefix", msg) + } +} + +// /all: POST body commandcode_api_key overrides the configured session_token +// (and config key) — the Command Code provider goes through /alpha + Bearer. +func TestHandleManagement_AllUsage_CommandCodeAPIKeyOverride(t *testing.T) { + ts := newAlphaTestServer(t, "user_all-key") + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + cfg := &PluginConfig{ + SessionToken: "cfg-token-must-lose", + APIBase: ts.URL, + } + + reqBody, _ := json.Marshal(map[string]string{"commandcode_api_key": "user_all-key"}) + req := ManagementRequest{ + Method: http.MethodPost, + Path: "/plugins/commandcode/all", + Body: reqBody, + } + resp, err := HandleManagement(context.Background(), req, cfg) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200 (commandcode succeeded via /alpha), body=%s", resp.StatusCode, string(resp.Body)) + } + + var all AllUsageResponse + if err := json.Unmarshal(resp.Body, &all); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !all.OK { + t.Error("expected ok=true") + } + var ccUsage FormattedUsageResponse + if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK { + t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage) + } + if ccUsage.Credits.TotalCredits != 555 { + t.Errorf("commandcode total_credits = %v, want 555 (alpha payload)", ccUsage.Credits.TotalCredits) + } + // OpenCode key missing → local-credential error for that provider only. + if _, present := all.Errors["opencode"]; !present { + t.Errorf("expected errors[opencode], got %v", all.Errors) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go index 9c41622..68fe4f1 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -13,7 +13,7 @@ import ( const ( PluginID = "commandcode" PluginName = "commandcode" - PluginVersion = "0.4.5" + PluginVersion = "0.5.0" PluginAuthor = "zgs225" PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode" PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg" @@ -22,12 +22,13 @@ const ( // PluginConfig holds the runtime configuration parsed from YAML. type PluginConfig struct { - mu sync.RWMutex - SessionToken string `yaml:"session_token" json:"session_token"` - APIBase string `yaml:"api_base" json:"api_base"` - OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"` - OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"` - OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"` + mu sync.RWMutex + SessionToken string `yaml:"session_token" json:"session_token"` + CommandCodeAPIKey string `yaml:"commandcode_api_key" json:"commandcode_api_key"` + APIBase string `yaml:"api_base" json:"api_base"` + OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"` + OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"` + OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"` } // UpdateFromYAML updates the configuration from raw YAML bytes. @@ -36,11 +37,12 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error { return nil } var tmp struct { - SessionToken string `yaml:"session_token"` - APIBase string `yaml:"api_base"` - OpenCodeAPIKey string `yaml:"opencode_api_key"` - OpenCodeAPIKeys []string `yaml:"opencode_api_keys"` - OpenCodeAPIBase string `yaml:"opencode_api_base"` + SessionToken string `yaml:"session_token"` + CommandCodeAPIKey string `yaml:"commandcode_api_key"` + APIBase string `yaml:"api_base"` + OpenCodeAPIKey string `yaml:"opencode_api_key"` + OpenCodeAPIKeys []string `yaml:"opencode_api_keys"` + OpenCodeAPIBase string `yaml:"opencode_api_base"` } if err := yaml.Unmarshal(raw, &tmp); err != nil { return fmt.Errorf("unmarshal config_yaml: %w", err) @@ -52,6 +54,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error { if tmp.SessionToken != "" { c.SessionToken = ExtractSessionToken(tmp.SessionToken) } + if tmp.CommandCodeAPIKey != "" { + // Provider API key is a plain Bearer token; do not run it through + // ExtractSessionToken (that is Command Code cookie specific). + c.CommandCodeAPIKey = strings.TrimSpace(tmp.CommandCodeAPIKey) + } if tmp.APIBase != "" { c.APIBase = strings.TrimRight(tmp.APIBase, "/") } @@ -85,6 +92,15 @@ func (c *PluginConfig) GetSessionToken() string { return c.SessionToken } +// GetCommandCodeAPIKey safely returns the configured Command Code Provider +// API key. When non-empty, usage queries go through the /alpha endpoints +// with Bearer auth instead of the session-cookie /internal endpoints. +func (c *PluginConfig) GetCommandCodeAPIKey() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.CommandCodeAPIKey +} + // SetSessionToken safely sets the session token. func (c *PluginConfig) SetSessionToken(token string) { c.mu.Lock() @@ -219,6 +235,11 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) { Type: "string", Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)", }, + { + Name: "commandcode_api_key", + Type: "string", + Description: "Command Code Provider API key (user_…); when set, usage queries go through the /alpha endpoints with Bearer auth — no session cookie needed", + }, { Name: "api_base", Type: "string", diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index b78d92b..bca24f9 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -46,15 +46,15 @@ api_base: "https://custom-api.commandcode.ai" t.Errorf("Capabilities.ManagementAPI = false, want true") } - // Verify config fields (v0.4.0: 4 → 5, adds opencode_api_keys) - if len(reg.Metadata.ConfigFields) != 5 { - t.Fatalf("ConfigFields len = %d, want 5", len(reg.Metadata.ConfigFields)) + // Verify config fields (v0.5.0: 5 → 6, adds commandcode_api_key) + if len(reg.Metadata.ConfigFields) != 6 { + t.Fatalf("ConfigFields len = %d, want 6", len(reg.Metadata.ConfigFields)) } fieldNames := map[string]bool{} for _, f := range reg.Metadata.ConfigFields { fieldNames[f.Name] = true } - if !fieldNames["session_token"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] { + if !fieldNames["session_token"] || !fieldNames["commandcode_api_key"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] { t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields) } @@ -83,6 +83,44 @@ session_token: "new-token-abc" } } +func TestPluginConfig_CommandCodeAPIKey(t *testing.T) { + p := NewPlugin() + // Trimmed, and NOT run through ExtractSessionToken (it is a plain + // Bearer token, not a Command Code cookie string). + configYAML := []byte("commandcode_api_key: \" user_abc123xyz \"\n") + lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML}) + if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil { + t.Fatalf("handleMethod(plugin.register) error: %v", err) + } + if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" { + t.Errorf("CommandCodeAPIKey = %q, want user_abc123xyz (trimmed, raw)", got) + } + + // Whitespace-only value clears the field. + p2 := NewPlugin() + spaceReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("commandcode_api_key: \" \"\n")}) + if _, err := p2.HandleMethod("plugin.register", spaceReq); err != nil { + t.Fatalf("handleMethod(plugin.register) error: %v", err) + } + if got := p2.config.GetCommandCodeAPIKey(); got != "" { + t.Errorf("CommandCodeAPIKey = %q, want empty (whitespace-only)", got) + } + + // Omitting the key in a reconfigure must not clear a configured value. + reconfReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("session_token: \"tok\"\n")}) + if _, err := p.HandleMethod("plugin.reconfigure", reconfReq); err != nil { + t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err) + } + if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" { + t.Errorf("CommandCodeAPIKey after reconfigure = %q, want kept user_abc123xyz", got) + } + + // Default is empty. + if got := NewPlugin().config.GetCommandCodeAPIKey(); got != "" { + t.Errorf("default CommandCodeAPIKey = %q, want empty", got) + } +} + func TestPluginAuthIdentifier_NotHandled(t *testing.T) { p := NewPlugin() raw, err := p.HandleMethod("auth.identifier", nil) diff --git a/plugin/quota_page.go b/plugin/quota_page.go index 8c6de03..db5a839 100644 --- a/plugin/quota_page.go +++ b/plugin/quota_page.go @@ -918,7 +918,7 @@ const QuotaPageHTML = `