diff --git a/README.md b/README.md index 186a95b..62c8c3c 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,11 @@ plugins: priority: 1 session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串 api_base: "https://api.commandcode.ai" # 可选,默认为官方接口 - opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # v0.3.0+ 可选,OpenCode Go 用量查询 - opencode_api_base: "https://opencode.ai/zen/go/v1" # v0.3.0+ 可选,默认为官方接口 + opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+) + # v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口 + opencode_api_keys: + - "sk-KEY1..." + - "sk-KEY2..." ``` --- @@ -212,51 +215,71 @@ plugins: - **端点**:`GET /v0/management/plugins/commandcode/opencode/usage`(认证同上,仅读插件配置;凭据覆盖走 POST) - **端点**:`POST /v0/management/plugins/commandcode/opencode/usage` -- **POST 请求体**: +- **POST 请求体**(多 key 数组优先;scalar 为 v0.3.0 兼容): ```json -{ "opencode_api_key": "sk-YOUR_TEMPORARY_KEY" } +{ "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] } ``` -- **响应示例**: +- **响应(v0.4.0+,逐 key 结果数组)**: ```json { "ok": true, "provider": "opencode_go", - "windows": { - "rolling": { "status": "ok", "percent": 4, "exceeded": false, - "reset_at": "2026-09-17T06:58:53Z", "reset_in_seconds": 2520 }, - "weekly": { "status": "ok", "percent": 46, "exceeded": false, - "reset_at": "2026-09-21T00:00:00Z", "reset_in_seconds": 259200 }, - "monthly": { "status": "ok", "percent": 23, "exceeded": false, - "reset_at": "2026-10-14T09:13:49Z", "reset_in_seconds": 1728000 } - }, - "updated_at": "2026-09-16T12:00:00Z" + "keys": [ + { + "key_id": "sk-L…KqYB", + "ok": true, + "windows": { + "rolling": { "status": "ok", "percent": 4, "exceeded": false, + "reset_at": "2026-09-17T06:58:53Z", "reset_in_seconds": 2520 }, + "weekly": { "status": "ok", "percent": 46, "exceeded": false, + "reset_at": "2026-09-21T00:00:00Z", "reset_in_seconds": 259200 }, + "monthly": { "status": "ok", "percent": 23, "exceeded": false, + "reset_at": "2026-10-14T09:13:49Z", "reset_in_seconds": 1728000 } + }, + "updated_at": "2026-09-16T12:00:00Z", + "status_code": 200 + }, + { + "key_id": "sk-U…PNHn", + "ok": false, + "updated_at": "2026-09-16T12:00:01Z", + "status_code": 401, + "error": "opencode upstream returned 401: check opencode_api_key" + } + ], + "updated_at": "2026-09-16T12:00:01Z" } ``` +- `key_id` 为服务端脱敏标识(前4+…+后4),原始 key 永不出现在响应中;失败 key 无 `windows` 字段,单 key 失败不影响其他 key。 +- **HTTP 状态**:≥1 key 成功 → 200;key 全配但全失败 → 502;未配置任何 key → 400。 + ### 5. 管理 API: 聚合查询 (`all`) - **端点**:`GET /v0/management/plugins/commandcode/all`(仅读插件配置) - **端点**:`POST /v0/management/plugins/commandcode/all` -- **POST 请求体**(可只带其一): +- **POST 请求体**(可只带其一;多 key 覆盖为数组): ```json -{ "session_token": "...", "opencode_api_key": "sk-..." } +{ "session_token": "...", "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] } ``` -- **部分失败语义**:HTTP 200 表示至少一个 provider 成功;失败 provider 记入 `errors`,其响应字段(`commandcode`/`opencode`)整个省略;全失败且为本地凭据缺失 → 400,全失败且为上游错误 → 502。 +- **部分失败语义**:HTTP 200 表示至少一个 provider(Command Code 或 ≥1 个 OpenCode key)成功;失败 provider 记入 `errors`,其响应字段整个省略;全失败且为本地凭据缺失 → 400,全失败且为上游错误 → 502。 ```json { "ok": true, "commandcode": { "ok": true, "plan": {...}, "credits": {...}, "window_limits": {...}, "updated_at": "..." }, - "opencode": { "ok": true, "provider": "opencode_go", "windows": {...}, "updated_at": "..." }, + "opencode": { "ok": true, "provider": "opencode_go", "keys": [ ...同上... ], "updated_at": "..." }, "updated_at": "2026-09-16T12:00:00Z" } ``` +> **v0.4.0 breaking note**:`opencode` 字段从单 key 对象变为 `{ok, provider, keys[], updated_at}` 多 key 结构(keys[].windows 为 v0.3.0 原窗口结构)。唯一消费方是同仓 QuotaCard 资源页,已同版本同步更新。 + --- ## 用量数据结构说明 @@ -275,9 +298,10 @@ plugins: | `window_limits.five_hour.reset_in_seconds`| `int64` | 距离 5 小时窗口重置的剩余秒数 | | `window_limits.weekly.*` | - | 每周限额对应指标(结构同 5 小时窗口) | | `windows..status` | `string` | OpenCode Go 窗口状态(`"ok"`/上游其他值,未知值不报错) | -| `windows.<...>.percent` | `float64` | OpenCode Go 窗口使用百分比(0-100,钳制) | -| `windows.<...>.exceeded` | `bool` | `percent >= 100` 或上游 `status == "exceeded"` | -| `windows.<...>.reset_at` / `reset_in_seconds` | `string` / `int64` | OpenCode Go 窗口重置时间(解析失败优雅降级为空/0) | +| `keys[].key_id` | `string` | 服务端脱敏 key 标识(前4+…+后4),原始 key 不出响应 | +| `keys[].ok` | `bool` | 该 key 查询是否成功(单 key 401 隔离) | +| `keys[].windows.<...>` | `object` | 成功 key 的三窗口指标(结构同上;失败 key 无此字段) | +| `keys[].status_code` / `error` | `int` / `string` | 该 key 上游 HTTP 状态与失败原因 | --- diff --git a/plugin/management.go b/plugin/management.go index f6dcbc8..8a8dba8 100644 --- a/plugin/management.go +++ b/plugin/management.go @@ -273,36 +273,91 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI } // 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. +// Credentials can be overridden via POST body only (opencode_api_keys list / +// opencode_api_key scalar); GET queries are read-only against the plugin +// config — query parameter overrides are intentionally not supported to keep +// secrets out of URLs. +// +// The response is the multi-key OpenCodeMultiKeyResponse envelope (v0.4.0): +// >=1 key succeeded → 200; keys configured but all upstream-failed → 502; no +// keys configured at all → 400 with a top-level "no opencode api keys +// configured ..." error. func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) { - apiKey := "" apiBase := "" + var keys []string - if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 { + if 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"` + OpenCodeAPIKeys []string `json:"opencode_api_keys"` + 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 + keys = normalizeOpenCodeKeys(body.OpenCodeAPIKeys) + if len(keys) == 0 { + single := strings.TrimSpace(body.OpenCodeAPIKey) + if single == "" { + single = strings.TrimSpace(body.APIKey) + } + if single != "" { + keys = []string{single} + } } apiBase = body.OpenCodeAPIBase } // Fallback to plugin config - if apiKey == "" && cfg != nil { - apiKey = cfg.GetOpenCodeAPIKey() + if len(keys) == 0 && cfg != nil { + keys = cfg.GetOpenCodeAPIKeys() } if apiBase == "" && cfg != nil { apiBase = cfg.GetOpenCodeAPIBase() } - return handleOpenCodeUsageWithKey(ctx, apiBase, apiKey, req.HostCallbackID) + now := time.Now().UTC() + if len(keys) == 0 { + resBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{ + OK: false, + Provider: "opencode_go", + Keys: []OpenCodeKeyResult{}, + UpdatedAt: now.Format(time.RFC3339), + Error: "no opencode api keys configured. Configure opencode_api_keys (YAML list) or opencode_api_key in the plugin config, or pass opencode_api_keys in the POST body", + }) + return ManagementResponse{ + StatusCode: http.StatusBadRequest, + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Body: resBytes, + }, nil + } + + results := QueryOpenCodeKeys(ctx, apiBase, keys, req.HostCallbackID) + succeeded := 0 + for _, r := range results { + if r.OK { + succeeded++ + } + } + + statusCode := http.StatusOK + if succeeded == 0 { + statusCode = http.StatusBadGateway + } + resBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{ + OK: succeeded > 0, + Provider: "opencode_go", + Keys: results, + UpdatedAt: now.Format(time.RFC3339), + }) + return ManagementResponse{ + StatusCode: statusCode, + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Body: resBytes, + }, nil } // handleAllUsage serves GET/POST /plugins/commandcode/all: it queries both @@ -314,24 +369,30 @@ 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 := "" - opencodeKey := "" + opencodeKeys := []string{} - if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 { + if req.Method == http.MethodPost && len(req.Body) > 0 { var body struct { - SessionToken string `json:"session_token"` - OpencodeAPIKey string `json:"opencode_api_key"` + SessionToken string `json:"session_token"` + OpencodeAPIKeys []string `json:"opencode_api_keys"` + OpencodeAPIKey string `json:"opencode_api_key"` } _ = json.Unmarshal(req.Body, &body) sessionToken = body.SessionToken - opencodeKey = body.OpencodeAPIKey + opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys) + if len(opencodeKeys) == 0 { + if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" { + opencodeKeys = []string{single} + } + } } // Fallback to plugin config if sessionToken == "" && cfg != nil { sessionToken = cfg.GetSessionToken() } - if opencodeKey == "" && cfg != nil { - opencodeKey = cfg.GetOpenCodeAPIKey() + if len(opencodeKeys) == 0 && cfg != nil { + opencodeKeys = cfg.GetOpenCodeAPIKeys() } apiBase := "" if cfg != nil { @@ -364,25 +425,34 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi } } - // 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++ + // Provider 2: OpenCode Go, one sequential query per configured key + // (v0.4.0). >=1 key success counts the provider as successful and the + // multi-key payload is inlined; keys configured but all failed is an + // upstream failure (a configured-but-invalid key is NOT a local config + // problem); zero keys configured is a local missing-credential error. + if len(opencodeKeys) > 0 { + results := QueryOpenCodeKeys(ctx, ocAPIBase, opencodeKeys, req.HostCallbackID) + succeededKeys := 0 + for _, r := range results { + if r.OK { + succeededKeys++ } } + if succeededKeys > 0 { + ocBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{ + OK: true, + Provider: "opencode_go", + Keys: results, + UpdatedAt: now.Format(time.RFC3339), + }) + resp.OpenCode = ocBytes + succeeded++ + } else { + errs["opencode"] = fmt.Sprintf("all %d opencode keys failed", len(opencodeKeys)) + upstreamFailed++ + } } else { - errs["opencode"] = "missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request body" + errs["opencode"] = "no opencode api keys configured. Configure opencode_api_keys (YAML list) or opencode_api_key in the plugin config, or pass opencode_api_keys in the POST body" localMissing++ } @@ -419,6 +489,7 @@ func isLocalCredentialError(msg string) bool { for _, prefix := range []string{ "session_token is required", "opencode_api_key is required", + "no opencode api keys configured", } { if strings.HasPrefix(msg, prefix) { return true @@ -427,81 +498,51 @@ func isLocalCredentialError(msg string) bool { 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 +// queryOpenCodeKey runs the OpenCode Go usage query for a single API key and +// returns a typed per-key result, shared by handleOpenCodeUsage and +// handleAllUsage (via QueryOpenCodeKeys). The handler layer is responsible +// for marshaling the aggregate response and picking the HTTP status code. +func queryOpenCodeKey(ctx context.Context, apiBase, key, hostCallbackID string) (OpenCodeKeyResult, error) { + now := time.Now().UTC() + res := OpenCodeKeyResult{ + KeyID: MaskAPIKey(key), + UpdatedAt: now.Format(time.RFC3339), } - raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, apiKey, hostCallbackID) + if strings.TrimSpace(key) == "" { + res.StatusCode = http.StatusBadRequest + res.Error = "opencode_api_key is required. Configure opencode_api_keys in plugin config or pass it in the request" + return res, nil + } + + raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, key, 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 + res.StatusCode = statusCode + res.Error = fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error()) + return res, 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 + res.StatusCode = statusCode + res.Error = fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode) + return res, nil } - usage, errParse := ParseOpenCodeUsage(raw, time.Now().UTC()) + usage, errParse := ParseOpenCodeUsage(raw, now) 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 + res.StatusCode = http.StatusBadGateway + res.Error = "failed to parse opencode upstream usage: " + errParse.Error() + return res, nil } - resBytes, _ := json.Marshal(usage) - return ManagementResponse{ - StatusCode: http.StatusOK, - Headers: map[string][]string{ - "Content-Type": {"application/json"}, - }, - Body: resBytes, - }, nil + res.OK = true + res.StatusCode = http.StatusOK + res.Windows = &usage.Windows + res.UpdatedAt = usage.UpdatedAt + return res, nil } // extractErrorResponseMessage pulls the "error" field out of a JSON error body. diff --git a/plugin/management_test.go b/plugin/management_test.go index 7c4f4b9..62d255b 100644 --- a/plugin/management_test.go +++ b/plugin/management_test.go @@ -74,6 +74,9 @@ 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.0") { + t.Errorf("Body does not contain version badge v0.4.0") + } } } @@ -230,19 +233,30 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body)) } - var usage OpenCodeFormattedUsageResponse + // v0.4.0: the response is the multi-key envelope even for a single key. + var usage OpenCodeMultiKeyResponse if err := json.Unmarshal(resp.Body, &usage); err != nil { t.Fatalf("unmarshal body error: %v", err) } if !usage.OK || usage.Provider != "opencode_go" { t.Fatalf("unexpected response: ok=%v provider=%q", usage.OK, usage.Provider) } - if usage.Windows.Rolling.Percent != 4 || usage.Windows.Weekly.Percent != 46 || usage.Windows.Monthly.Percent != 23 { - t.Errorf("windows percents = %v/%v/%v, want 4/46/23", - usage.Windows.Rolling.Percent, usage.Windows.Weekly.Percent, usage.Windows.Monthly.Percent) + if len(usage.Keys) != 1 || !usage.Keys[0].OK { + t.Fatalf("expected exactly one successful key, got %+v", usage.Keys) } - if usage.Windows.Weekly.ResetInSeconds <= 0 { - t.Errorf("weekly reset_in_seconds = %d, want > 0", usage.Windows.Weekly.ResetInSeconds) + if usage.Keys[0].Windows == nil { + t.Fatal("keys[0].windows = nil, want non-nil on success") + } + if usage.Keys[0].Windows.Rolling.Percent != 4 || usage.Keys[0].Windows.Weekly.Percent != 46 || usage.Keys[0].Windows.Monthly.Percent != 23 { + t.Errorf("windows percents = %v/%v/%v, want 4/46/23", + usage.Keys[0].Windows.Rolling.Percent, usage.Keys[0].Windows.Weekly.Percent, usage.Keys[0].Windows.Monthly.Percent) + } + if usage.Keys[0].Windows.Weekly.ResetInSeconds <= 0 { + t.Errorf("weekly reset_in_seconds = %d, want > 0", usage.Keys[0].Windows.Weekly.ResetInSeconds) + } + // The raw key from the POST body must never appear in the response. + if strings.Contains(string(resp.Body), "sk-opencode-override") && tc.method == http.MethodPost { + t.Errorf("response leaks the raw override key: %s", string(resp.Body)) } }) } @@ -252,6 +266,175 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) { } } +// /opencode/usage status matrix (v0.4.0): >=1 key success → 200; one success +// + one 401 → 200 with keys[1].ok=false and no windows; all 401 → 502; no +// keys configured → 400 with the "no opencode api keys configured" prefix. +func TestHandleManagement_OpencodeUsage_MultiKeyMatrix(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Header.Get("Authorization") { + case "Bearer sk-good-AAAA", "Bearer sk-good-ZZZZ": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(mockOpencodeUsageJSON)) + default: + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid api key"}`)) + } + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + postKeys := func(keys ...string) ManagementRequest { + body, _ := json.Marshal(map[string]any{ + "opencode_api_keys": keys, + "opencode_api_base": ts.URL, + }) + return ManagementRequest{ + Method: http.MethodPost, + Path: "/plugins/commandcode/opencode/usage", + Body: body, + } + } + + t.Run("both keys succeed → 200", func(t *testing.T) { + resp, err := HandleManagement(context.Background(), postKeys("sk-good-AAAA", "sk-good-ZZZZ"), nil) + 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 OpenCodeMultiKeyResponse + if err := json.Unmarshal(resp.Body, &usage); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !usage.OK || len(usage.Keys) != 2 || !usage.Keys[0].OK || !usage.Keys[1].OK { + t.Errorf("unexpected response: %+v", usage) + } + if usage.Error != "" { + t.Errorf("top-level error = %q, want empty when keys are configured", usage.Error) + } + }) + + t.Run("one success one 401 → 200 with failed key isolated", func(t *testing.T) { + resp, err := HandleManagement(context.Background(), postKeys("sk-good-AAAA", "sk-bad-BBBB"), nil) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("StatusCode = %d, want 200 (>=1 key succeeded), body=%s", resp.StatusCode, string(resp.Body)) + } + var usage OpenCodeMultiKeyResponse + if err := json.Unmarshal(resp.Body, &usage); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !usage.OK || len(usage.Keys) != 2 { + t.Fatalf("unexpected response: %+v", usage) + } + if !usage.Keys[0].OK || usage.Keys[0].Windows == nil { + t.Errorf("keys[0] = %+v, want ok with windows", usage.Keys[0]) + } + if usage.Keys[1].OK || usage.Keys[1].Windows != nil { + t.Errorf("keys[1] = %+v, want not-ok with nil windows", usage.Keys[1]) + } + if usage.Keys[1].StatusCode != http.StatusUnauthorized { + t.Errorf("keys[1].status_code = %d, want 401", usage.Keys[1].StatusCode) + } + // windows must be omitted from the JSON for the failed key, not + // serialized as null or a zero-value struct. + var raw struct { + Keys []struct { + Windows json.RawMessage `json:"windows"` + } `json:"keys"` + } + if err := json.Unmarshal(resp.Body, &raw); err != nil { + t.Fatalf("unmarshal raw error: %v", err) + } + if len(raw.Keys[1].Windows) != 0 { + t.Errorf("keys[1].windows in JSON = %s, want omitted", string(raw.Keys[1].Windows)) + } + if !strings.Contains(usage.Keys[1].Error, "opencode upstream returned 401") { + t.Errorf("keys[1].error = %q, want upstream 401 mention", usage.Keys[1].Error) + } + if strings.Contains(string(resp.Body), "sk-bad-BBBB") { + t.Errorf("response leaks the raw key: %s", string(resp.Body)) + } + }) + + t.Run("all keys 401 → 502", func(t *testing.T) { + resp, err := HandleManagement(context.Background(), postKeys("sk-bad-CCCC", "sk-bad-DDDD"), nil) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("StatusCode = %d, want 502 (all keys upstream-failed), body=%s", resp.StatusCode, string(resp.Body)) + } + var usage OpenCodeMultiKeyResponse + if err := json.Unmarshal(resp.Body, &usage); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if usage.OK { + t.Errorf("OK = true, want false when all keys fail") + } + if usage.Error != "" { + t.Errorf("top-level error = %q, want empty (per-key errors carry the detail)", usage.Error) + } + }) + + t.Run("no keys configured → 400", func(t *testing.T) { + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/opencode/usage", + } + resp, err := HandleManagement(context.Background(), req, &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)) + } + var usage OpenCodeMultiKeyResponse + if err := json.Unmarshal(resp.Body, &usage); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !strings.HasPrefix(usage.Error, "no opencode api keys configured") { + t.Errorf("top-level error = %q, want prefix 'no opencode api keys configured'", usage.Error) + } + }) + + t.Run("POST body opencode_api_keys overrides config and wins over scalar", func(t *testing.T) { + body, _ := json.Marshal(map[string]any{ + "opencode_api_key": "sk-scalar-must-lose", + "opencode_api_keys": []string{"sk-good-AAAA"}, + "opencode_api_base": ts.URL, + }) + req := ManagementRequest{ + Method: http.MethodPost, + Path: "/plugins/commandcode/opencode/usage", + Body: body, + } + cfg := &PluginConfig{OpenCodeAPIKey: "sk-config-must-lose", OpenCodeAPIBase: 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=%s", resp.StatusCode, string(resp.Body)) + } + var usage OpenCodeMultiKeyResponse + if err := json.Unmarshal(resp.Body, &usage); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(usage.Keys) != 1 || usage.Keys[0].KeyID != MaskAPIKey("sk-good-AAAA") { + t.Errorf("keys = %+v, want only the body-list key (list wins over scalar and config)", usage.Keys) + } + }) +} + // Regression: /plugins/commandcode/all must not be swallowed by the generic // "/usage" suffix match nor miss its dedicated handler. func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) { @@ -314,10 +497,14 @@ func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) { if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK { t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage) } - var ocUsage OpenCodeFormattedUsageResponse + // v0.4.0: the opencode field carries the multi-key envelope. + var ocUsage OpenCodeMultiKeyResponse if err := json.Unmarshal(all.OpenCode, &ocUsage); err != nil || !ocUsage.OK { t.Errorf("opencode payload invalid: err=%v usage=%+v", err, ocUsage) } + if len(ocUsage.Keys) != 1 || !ocUsage.Keys[0].OK || ocUsage.Keys[0].Windows == nil { + t.Errorf("opencode keys = %+v, want one successful key with windows", ocUsage.Keys) + } } // Partial failure: one provider fails upstream → ok stays true, the failed @@ -377,8 +564,10 @@ func TestHandleManagement_AllUsage_PartialFailure(t *testing.T) { if _, present := all.Errors["opencode"]; !present { t.Errorf("expected errors[opencode] to be set, got %v", all.Errors) } - if !strings.Contains(all.Errors["opencode"], "opencode upstream returned 500") { - t.Errorf("errors[opencode] = %q, want it to mention 'opencode upstream returned 500'", all.Errors["opencode"]) + // v0.4.0: a configured-but-failed key is an upstream failure; with the + // single configured key failing, the aggregate message is "all N keys failed". + if !strings.Contains(all.Errors["opencode"], "all 1 opencode keys failed") { + t.Errorf("errors[opencode] = %q, want it to mention 'all 1 opencode keys failed'", all.Errors["opencode"]) } // opencode field must be omitted (omitempty), not serialized as "null". if strings.Contains(string(resp.Body), `"opencode":null`) { @@ -450,6 +639,154 @@ func TestHandleManagement_AllUsage_Upstream400NotMisclassified(t *testing.T) { } } +// /all matrix (v0.4.0): Command Code upstream down + all opencode keys 401 +// → every failure is upstream → 502, with the aggregate "all N keys failed" +// message in errors["opencode"]. +func TestHandleManagement_AllUsage_CCUpstreamDown_OpenCodeAll401(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/usage": + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid api key"}`)) + default: // commandcode internal endpoints + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"cc exploded"}`)) + } + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + cfg := &PluginConfig{ + SessionToken: "configured-token", + APIBase: ts.URL, + OpenCodeAPIKeys: []string{"sk-bad-AAAA", "sk-bad-BBBB"}, + OpenCodeAPIBase: ts.URL, + } + + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/all", + } + resp, err := HandleManagement(context.Background(), req, cfg) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("StatusCode = %d, want 502 (all failures upstream), body=%s", resp.StatusCode, string(resp.Body)) + } + + var all AllUsageResponse + if err := json.Unmarshal(resp.Body, &all); err != nil { + t.Fatalf("unmarshal body error: %v", err) + } + if all.OK { + t.Error("expected ok=false") + } + if !strings.Contains(all.Errors["opencode"], "all 2 opencode keys failed") { + t.Errorf("errors[opencode] = %q, want 'all 2 opencode keys failed'", all.Errors["opencode"]) + } +} + +// /all matrix: Command Code upstream down + one opencode key succeeds → 200 +// (partial failure); the multi-key opencode payload is inlined. +func TestHandleManagement_AllUsage_CCUpstreamDown_OpenCodeOneOK(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/usage" && r.Header.Get("Authorization") == "Bearer sk-good-AAAA": + _, _ = w.Write([]byte(mockOpencodeUsageJSON)) + case r.URL.Path == "/usage": + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid api key"}`)) + default: // commandcode internal endpoints + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"cc exploded"}`)) + } + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + cfg := &PluginConfig{ + SessionToken: "configured-token", + APIBase: ts.URL, + OpenCodeAPIKeys: []string{"sk-good-AAAA", "sk-bad-BBBB"}, + OpenCodeAPIBase: ts.URL, + } + + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/all", + } + 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 (opencode partial success), body=%s", resp.StatusCode, string(resp.Body)) + } + + var all AllUsageResponse + if err := json.Unmarshal(resp.Body, &all); err != nil { + t.Fatalf("unmarshal body error: %v", err) + } + if !all.OK { + t.Error("expected ok=true (>=1 provider succeeded)") + } + if _, present := all.Errors["commandcode"]; !present { + t.Errorf("expected errors[commandcode], got %v", all.Errors) + } + if _, present := all.Errors["opencode"]; present { + t.Errorf("errors[opencode] must be absent on partial success, got %q", all.Errors["opencode"]) + } + var oc OpenCodeMultiKeyResponse + if err := json.Unmarshal(all.OpenCode, &oc); err != nil || !oc.OK { + t.Fatalf("opencode payload invalid: err=%v oc=%+v", err, oc) + } + if len(oc.Keys) != 2 || !oc.Keys[0].OK || oc.Keys[1].OK { + t.Errorf("opencode keys = %+v, want [ok, failed]", oc.Keys) + } + if strings.Contains(string(resp.Body), "sk-good-AAAA") || strings.Contains(string(resp.Body), "sk-bad-BBBB") { + t.Errorf("/all response leaks a raw opencode key: %s", string(resp.Body)) + } +} + +func TestIsLocalCredentialError(t *testing.T) { + local := []string{ + "session_token is required. Configure ...", + "opencode_api_key is required. Configure ...", + // v0.4.0 prefix: zero opencode keys configured is a local problem. + "no opencode api keys configured. Configure opencode_api_keys (YAML list) ...", + } + for _, msg := range local { + if !isLocalCredentialError(msg) { + t.Errorf("isLocalCredentialError(%q) = false, want true", msg) + } + } + + upstream := []string{ + "opencode upstream returned 401: check opencode_api_key", + "opencode upstream request failed: dial tcp: connection refused", + "all 2 opencode keys failed", + "upstream returned non-200 status", + "failed to parse opencode upstream usage: unexpected end of JSON input", + "", + } + for _, msg := range upstream { + if isLocalCredentialError(msg) { + t.Errorf("isLocalCredentialError(%q) = true, want false", msg) + } + } +} + // Unknown path after the new routes still 404s. func TestHandleManagement_UnknownPath(t *testing.T) { req := ManagementRequest{ diff --git a/plugin/plugin.go b/plugin/plugin.go index 8e22d39..d16f225 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -13,7 +13,7 @@ import ( const ( PluginID = "commandcode" PluginName = "commandcode" - PluginVersion = "0.3.0" + PluginVersion = "0.4.0" PluginAuthor = "zgs225" PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode" PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg" @@ -23,10 +23,11 @@ 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"` - OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"` + 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"` } // UpdateFromYAML updates the configuration from raw YAML bytes. @@ -35,10 +36,11 @@ 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"` - OpenCodeAPIBase string `yaml:"opencode_api_base"` + 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"` } if err := yaml.Unmarshal(raw, &tmp); err != nil { return fmt.Errorf("unmarshal config_yaml: %w", err) @@ -58,6 +60,15 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error { // ExtractSessionToken (that is Command Code cookie specific). c.OpenCodeAPIKey = strings.TrimSpace(tmp.OpenCodeAPIKey) } + // Merge rule: opencode_api_keys (YAML list) wins when non-empty after + // trimming/dedup; otherwise opencode_api_key (scalar) degrades to a + // single-key list; both empty means no keys. + c.OpenCodeAPIKeys = normalizeOpenCodeKeys(tmp.OpenCodeAPIKeys) + if len(c.OpenCodeAPIKeys) == 0 { + if single := strings.TrimSpace(tmp.OpenCodeAPIKey); single != "" { + c.OpenCodeAPIKeys = []string{single} + } + } if tmp.OpenCodeAPIBase != "" { c.OpenCodeAPIBase = strings.TrimRight(tmp.OpenCodeAPIBase, "/") } @@ -91,13 +102,48 @@ func (c *PluginConfig) GetAPIBase() string { return c.APIBase } -// GetOpenCodeAPIKey safely returns the OpenCode Go API key. +// GetOpenCodeAPIKey safely returns the single configured OpenCode Go API key +// (scalar opencode_api_key field; kept for backward compatibility). func (c *PluginConfig) GetOpenCodeAPIKey() string { c.mu.RLock() defer c.mu.RUnlock() return c.OpenCodeAPIKey } +// GetOpenCodeAPIKeys safely returns the configured OpenCode Go API keys. +// The list field wins; when it is empty the scalar OpenCodeAPIKey degrades +// to a single-key list (same merge rule as UpdateFromYAML). The returned +// slice is a copy; callers may not mutate it. +func (c *PluginConfig) GetOpenCodeAPIKeys() []string { + c.mu.RLock() + defer c.mu.RUnlock() + if len(c.OpenCodeAPIKeys) > 0 { + out := make([]string, len(c.OpenCodeAPIKeys)) + copy(out, c.OpenCodeAPIKeys) + return out + } + if c.OpenCodeAPIKey != "" { + return []string{c.OpenCodeAPIKey} + } + return nil +} + +// normalizeOpenCodeKeys trims each key, drops empties and dedups while +// preserving the original order. +func normalizeOpenCodeKeys(keys []string) []string { + out := make([]string, 0, len(keys)) + seen := make(map[string]bool, len(keys)) + for _, k := range keys { + k = strings.TrimSpace(k) + if k == "" || seen[k] { + continue + } + seen[k] = true + out = append(out, k) + } + return out +} + // GetOpenCodeAPIBase safely returns the OpenCode Go API base URL, // falling back to DefaultOpenCodeAPIBase when unset. func (c *PluginConfig) GetOpenCodeAPIBase() string { @@ -181,7 +227,12 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) { { Name: "opencode_api_key", Type: "string", - Description: "OpenCode Go API key (Bearer token used for https://opencode.ai/zen/go/v1/usage)", + Description: "OpenCode Go API key (single Bearer token; degraded path when opencode_api_keys is unset)", + }, + { + Name: "opencode_api_keys", + Type: "string", + Description: "OpenCode Go API keys as a YAML list (e.g. opencode_api_keys: [\"sk-KEY1\", \"sk-KEY2\"]); takes precedence over opencode_api_key", }, { Name: "opencode_api_base", diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index efe8af2..b78d92b 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 - if len(reg.Metadata.ConfigFields) != 4 { - t.Fatalf("ConfigFields len = %d, want 4", len(reg.Metadata.ConfigFields)) + // 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)) } 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_base"] { + if !fieldNames["session_token"] || !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) } @@ -169,4 +169,90 @@ func TestPluginConfig_OpenCode(t *testing.T) { if got := empty.config.GetOpenCodeAPIKey(); got != "" { t.Errorf("default OpenCodeAPIKey = %q, want empty", got) } + if got := empty.config.GetOpenCodeAPIKeys(); len(got) != 0 { + t.Errorf("default GetOpenCodeAPIKeys = %v, want empty", got) + } +} + +func TestPluginConfig_OpenCodeAPIKeys(t *testing.T) { + newCfg := func(t *testing.T, yaml string) *PluginConfig { + t.Helper() + cfg := &PluginConfig{} + if err := cfg.UpdateFromYAML([]byte(yaml)); err != nil { + t.Fatalf("UpdateFromYAML error: %v", err) + } + return cfg + } + + t.Run("list takes precedence over scalar", func(t *testing.T) { + cfg := newCfg(t, ` +opencode_api_key: "sk-scalar" +opencode_api_keys: + - " sk-key1 " + - "sk-key2" +`) + got := cfg.GetOpenCodeAPIKeys() + if len(got) != 2 || got[0] != "sk-key1" || got[1] != "sk-key2" { + t.Errorf("GetOpenCodeAPIKeys = %v, want [sk-key1 sk-key2] (list wins, trimmed)", got) + } + if cfg.GetOpenCodeAPIKey() != "sk-scalar" { + t.Errorf("GetOpenCodeAPIKey = %q, want sk-scalar (scalar field kept)", cfg.GetOpenCodeAPIKey()) + } + }) + + t.Run("scalar degrades to single-key list", func(t *testing.T) { + cfg := newCfg(t, ` +opencode_api_key: " sk-only " +`) + got := cfg.GetOpenCodeAPIKeys() + if len(got) != 1 || got[0] != "sk-only" { + t.Errorf("GetOpenCodeAPIKeys = %v, want [sk-only]", got) + } + }) + + t.Run("dedup preserve order and drop empties", func(t *testing.T) { + cfg := newCfg(t, ` +opencode_api_keys: + - "sk-b" + - "" + - " " + - "sk-a" + - "sk-b" + - "sk-c" + - "sk-a" +`) + got := cfg.GetOpenCodeAPIKeys() + want := []string{"sk-b", "sk-a", "sk-c"} + if len(got) != len(want) { + t.Fatalf("GetOpenCodeAPIKeys = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("GetOpenCodeAPIKeys[%d] = %q, want %q (order preserved, deduped)", i, got[i], want[i]) + } + } + }) + + t.Run("all empty yields no keys", func(t *testing.T) { + for _, yaml := range []string{ + `opencode_api_key: ""`, + "opencode_api_keys: []\nopencode_api_key: \" \"", + "opencode_api_keys:\n - \"\"\n - \" \"", + } { + cfg := newCfg(t, yaml) + if got := cfg.GetOpenCodeAPIKeys(); len(got) != 0 { + t.Errorf("yaml %q: GetOpenCodeAPIKeys = %v, want empty", yaml, got) + } + } + }) + + t.Run("getter returns a copy", func(t *testing.T) { + cfg := newCfg(t, "opencode_api_keys:\n - sk-a\n - sk-b\n") + got := cfg.GetOpenCodeAPIKeys() + got[0] = "mutated" + again := cfg.GetOpenCodeAPIKeys() + if again[0] != "sk-a" { + t.Errorf("GetOpenCodeAPIKeys not a copy: after mutation got %q", again[0]) + } + }) } diff --git a/plugin/quota_page.go b/plugin/quota_page.go index 9f0214a..c8a3f48 100644 --- a/plugin/quota_page.go +++ b/plugin/quota_page.go @@ -10,7 +10,7 @@ const QuotaPageHTML = ` - 用量配额 - Command Code + OpenCode Go - CLIProxyAPI + 用量配额 - CLIProxyAPI