From 61c50280b198009ceccd21e399d533235373d00a Mon Sep 17 00:00:00 2001 From: yuez Date: Fri, 18 Sep 2026 09:38:40 +0800 Subject: [PATCH] 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 --- .gitignore | 3 + README.md | 73 +++- plugin/management.go | 305 ++++++++++++++++- plugin/management_test.go | 324 +++++++++++++++++- plugin/plugin.go | 52 ++- plugin/plugin_test.go | 45 ++- plugin/quota_page.go | 689 ++++++++++++++++++++++++++++++++++++-- plugin/types.go | 57 ++++ plugin/usage.go | 119 ++++++- plugin/usage_test.go | 310 +++++++++++++++++ 10 files changed, 1903 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index db48194..e09d9e0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ coverage.txt *.zip checksums.txt dist/ + +# Internal planning docs (not for public repo) +docs/ diff --git a/README.md b/README.md index 519d349..186a95b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CLIProxyAPI Plugin ABI](https://img.shields.io/badge/C%20ABI-v1-emerald.svg)](https://help.router-for.me/plugin/development.html) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。 +[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 与 **OpenCode Go** 两个上游的配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard,Tab: Command Code / OpenCode Go / All)。 --- @@ -20,6 +20,8 @@ - [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard) - [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get) - [3. 管理 API: 测试用量 (`POST`)](#3-管理-api-测试用量-post) + - [4. 管理 API: OpenCode Go 用量 (`opencode/usage`)](#4-管理-api-opencode-go-用量-opencodeusage) + - [5. 管理 API: 聚合查询 (`all`)](#5-管理-api-聚合查询-all) - [用量数据结构说明](#用量数据结构说明) - [开发与测试](#开发与测试) - [许可证](#许可证) @@ -45,6 +47,11 @@ - 页面挂载于 `/v0/resource/plugins/commandcode/quota`。 - 零外部 CDN 依赖,纯内置 HTML + CSS + JS,深色/浅色模式自适应。 - 具有进度条颜色变化、5小时/周限额卡片、秒级动态重置倒计时、同源 `localStorage` 鉴权与一键刷新。 + - Tab 切换:Command Code / OpenCode Go / All(`#opencode` / `#all` hash 记忆状态)。 +6. **OpenCode Go 用量查询 (v0.3.0+)**: + - 上游接口:`GET https://opencode.ai/zen/go/v1/usage`,`Authorization: Bearer` 认证(同样走 `host.http.do` 优先 + `net/http` 兜底)。 + - 解析 rolling(5h)/ weekly / monthly 三个窗口的 `status`/`percent`/`resetsAt`,容忍未知 status 值。 + - 聚合端点 `/plugins/commandcode/all` 一次返回两个 provider,部分失败不拖死另一 provider。 --- @@ -76,7 +83,8 @@ └──────────────────────────────┼─────────────────────────┘ │ Upstream HTTPS ▼ - https://api.commandcode.ai/internal/billing/credits + https://api.commandcode.ai/internal/billing/credits + https://opencode.ai/zen/go/v1/usage (v0.3.0+) ``` --- @@ -126,6 +134,8 @@ 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+ 可选,默认为官方接口 ``` --- @@ -135,11 +145,11 @@ plugins: ### 1. 浏览器资源页 (`QuotaCard`) - **访问路径**:`GET http://:8317/v0/resource/plugins/commandcode/quota` -- **菜单名**:`Command Code 配额` +- **菜单名**:`用量配额` - **说明**: - 资源请求本身无需经过管理认证,可在浏览器中直接打开或嵌入仪表盘。 - - 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/usage` 请求数据。 - - 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key 或测试 Session Token。 + - 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/all` 请求数据(一次获取 Command Code + OpenCode Go)。 + - 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key、测试 Session Token 或 OpenCode API Key(仅当次请求生效,不持久化)。 ### 2. 管理 API: 查询用量 (`GET`) @@ -198,6 +208,55 @@ plugins: } ``` +### 4. 管理 API: OpenCode Go 用量 (`opencode/usage`) + +- **端点**:`GET /v0/management/plugins/commandcode/opencode/usage`(认证同上,仅读插件配置;凭据覆盖走 POST) +- **端点**:`POST /v0/management/plugins/commandcode/opencode/usage` +- **POST 请求体**: + +```json +{ "opencode_api_key": "sk-YOUR_TEMPORARY_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" +} +``` + +### 5. 管理 API: 聚合查询 (`all`) + +- **端点**:`GET /v0/management/plugins/commandcode/all`(仅读插件配置) +- **端点**:`POST /v0/management/plugins/commandcode/all` +- **POST 请求体**(可只带其一): + +```json +{ "session_token": "...", "opencode_api_key": "sk-..." } +``` + +- **部分失败语义**:HTTP 200 表示至少一个 provider 成功;失败 provider 记入 `errors`,其响应字段(`commandcode`/`opencode`)整个省略;全失败且为本地凭据缺失 → 400,全失败且为上游错误 → 502。 + +```json +{ + "ok": true, + "commandcode": { "ok": true, "plan": {...}, "credits": {...}, "window_limits": {...}, "updated_at": "..." }, + "opencode": { "ok": true, "provider": "opencode_go", "windows": {...}, "updated_at": "..." }, + "updated_at": "2026-09-16T12:00:00Z" +} +``` + --- ## 用量数据结构说明 @@ -215,6 +274,10 @@ plugins: | `window_limits.five_hour.reset_at` | `string` | 5小时窗口重置时间的 RFC3339 字符串 | | `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) | --- diff --git a/plugin/management.go b/plugin/management.go index 7ae4aa2..f6dcbc8 100644 --- a/plugin/management.go +++ b/plugin/management.go @@ -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" +} diff --git a/plugin/management_test.go b/plugin/management_test.go index 0efa909..7c4f4b9 100644 --- a/plugin/management_test.go +++ b/plugin/management_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestRegisterManagement(t *testing.T) { @@ -15,8 +16,8 @@ func TestRegisterManagement(t *testing.T) { t.Fatalf("RegisterManagement error: %v", err) } - if len(resp.Routes) != 2 { - t.Fatalf("len(Routes) = %d, want 2", len(resp.Routes)) + if len(resp.Routes) != 6 { + t.Fatalf("len(Routes) = %d, want 6", len(resp.Routes)) } if resp.Routes[0].Method != http.MethodGet || resp.Routes[0].Path != "/plugins/commandcode/usage" { t.Errorf("Route 0 mismatch: %+v", resp.Routes[0]) @@ -24,13 +25,27 @@ func TestRegisterManagement(t *testing.T) { if resp.Routes[1].Method != http.MethodPost || resp.Routes[1].Path != "/plugins/commandcode/usage" { t.Errorf("Route 1 mismatch: %+v", resp.Routes[1]) } + wantOpencode := []struct{ method, path string }{ + {http.MethodGet, "/plugins/commandcode/opencode/usage"}, + {http.MethodPost, "/plugins/commandcode/opencode/usage"}, + {http.MethodGet, "/plugins/commandcode/all"}, + {http.MethodPost, "/plugins/commandcode/all"}, + } + for i, w := range wantOpencode { + if resp.Routes[2+i].Method != w.method || resp.Routes[2+i].Path != w.path { + t.Errorf("Route %d mismatch: got %+v, want %s %s", 2+i, resp.Routes[2+i], w.method, w.path) + } + } if len(resp.Resources) != 1 { t.Fatalf("len(Resources) = %d, want 1", len(resp.Resources)) } - if resp.Resources[0].Path != "/quota" || resp.Resources[0].Menu != "Command Code 配额" { + if resp.Resources[0].Path != "/quota" || resp.Resources[0].Menu != "用量配额" { t.Errorf("Resource 0 mismatch: %+v", resp.Resources[0]) } + if resp.Resources[0].Description != "Command Code + OpenCode Go 用量与限额卡片" { + t.Errorf("Resource Description mismatch: %+v", resp.Resources[0]) + } } func TestHandleManagement_QuotaResource(t *testing.T) { @@ -56,8 +71,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) { t.Errorf("Content-Type = %v, want text/html", ct) } bodyStr := string(resp.Body) - if !strings.Contains(bodyStr, "Command Code 配额") { - t.Errorf("Body does not contain expected title") + if !strings.Contains(bodyStr, "用量配额") { + t.Errorf("Body does not contain expected menu text 用量配额") } } } @@ -150,3 +165,302 @@ func TestHandleManagement_PostUsage(t *testing.T) { t.Errorf("MonthlyCredits = %v, want 666", usage.Credits.MonthlyCredits) } } + +const mockOpencodeUsageJSON = `{"usage":{ + "rolling": {"status":"ok","percent":4, "resetsAt":"2026-09-17T06:58:53.171Z"}, + "weekly": {"status":"ok","percent":46,"resetsAt":"2026-09-21T00:00:00.000Z"}, + "monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"} +}}` + +// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated +// OpenCode handler and NOT swallowed by the generic "/usage" suffix match +// (which would route it to the Command Code handler). +func TestHandleManagement_OpencodeUsageRoute(t *testing.T) { + var sawAuthHeader bool + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/usage" { + t.Errorf("unexpected path %s, want /usage (Command Code handler must not be hit)", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-opencode-override" { + t.Errorf("Authorization = %q, want Bearer sk-opencode-override", got) + } + if r.Header.Get("Cookie") != "" { + t.Errorf("unexpected Cookie header on opencode request: %q", r.Header.Get("Cookie")) + } + sawAuthHeader = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(mockOpencodeUsageJSON)) + })) + defer ts.Close() + + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer func() { + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + }() + + reqBody, _ := json.Marshal(map[string]string{ + "opencode_api_key": "sk-opencode-override", + "opencode_api_base": ts.URL, + }) + + for _, tc := range []struct { + method string + body []byte + }{ + {http.MethodPost, reqBody}, + // GET with configured plugin config (no query override by design). + } { + t.Run(tc.method, func(t *testing.T) { + cfg := &PluginConfig{ + OpenCodeAPIKey: "sk-configured", + OpenCodeAPIBase: ts.URL, + } + req := ManagementRequest{ + Method: tc.method, + Path: "/v0/management/plugins/commandcode/opencode/usage", + Body: tc.body, + } + 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 OpenCodeFormattedUsageResponse + 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 usage.Windows.Weekly.ResetInSeconds <= 0 { + t.Errorf("weekly reset_in_seconds = %d, want > 0", usage.Windows.Weekly.ResetInSeconds) + } + }) + } + + if !sawAuthHeader { + t.Fatal("upstream never received Authorization header") + } +} + +// 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) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/usage" && r.Header.Get("Authorization") != "": + _, _ = w.Write([]byte(mockOpencodeUsageJSON)) + case r.URL.Path == "/internal/billing/credits": + _, _ = w.Write([]byte(`{"credits":{"monthlyCredits":888},"windowLimits":{"fiveHour":{"used":2,"cap":20}}}`)) + case r.URL.Path == "/internal/usage/summary": + _, _ = w.Write([]byte(`{"totalMonthlyCredits": 100}`)) + default: + t.Errorf("unexpected upstream request: %s %s", r.Method, 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: "configured-token", + APIBase: ts.URL, + OpenCodeAPIKey: "sk-configured", + 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, 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.Fatal("expected ok=true when both providers succeed") + } + if len(all.CommandCode) == 0 || len(all.OpenCode) == 0 { + t.Fatalf("expected both provider payloads, got commandcode=%d bytes opencode=%d bytes", + len(all.CommandCode), len(all.OpenCode)) + } + if len(all.Errors) != 0 { + t.Errorf("expected empty errors map, got %v", all.Errors) + } + + 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) + } + var ocUsage OpenCodeFormattedUsageResponse + if err := json.Unmarshal(all.OpenCode, &ocUsage); err != nil || !ocUsage.OK { + t.Errorf("opencode payload invalid: err=%v usage=%+v", err, ocUsage) + } +} + +// Partial failure: one provider fails upstream → ok stays true, the failed +// provider's field is omitted and the error lands in the errors map. +func TestHandleManagement_AllUsage_PartialFailure(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") != "": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"upstream exploded"}`)) + case r.URL.Path == "/internal/billing/credits": + _, _ = w.Write([]byte(`{"credits":{"monthlyCredits":888},"windowLimits":{"fiveHour":{"used":2,"cap":20}}}`)) + case r.URL.Path == "/internal/usage/summary": + _, _ = w.Write([]byte(`{"totalMonthlyCredits": 100}`)) + default: + t.Errorf("unexpected upstream request: %s %s", r.Method, 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: "configured-token", + APIBase: ts.URL, + OpenCodeAPIKey: "sk-configured", + OpenCodeAPIBase: ts.URL, + } + + req := ManagementRequest{ + Method: http.MethodPost, + Path: "/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 (partial failure), 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 despite one provider failing") + } + if len(all.CommandCode) == 0 { + t.Error("expected successful commandcode payload to be present") + } + 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"]) + } + // opencode field must be omitted (omitempty), not serialized as "null". + if strings.Contains(string(resp.Body), `"opencode":null`) { + t.Errorf("opencode field serialized as null: %s", string(resp.Body)) + } +} + +// All providers fail because credentials are missing → 400. +func TestHandleManagement_AllUsage_AllMissingConfig(t *testing.T) { + SetHostCaller(nil) + SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/all", + } + resp, err := HandleManagement(context.Background(), req, nil) + 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 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 _, present := all.Errors["commandcode"]; !present { + t.Errorf("expected errors[commandcode], got %v", all.Errors) + } + if _, present := all.Errors["opencode"]; !present { + t.Errorf("expected errors[opencode], got %v", all.Errors) + } +} + +// Regression for the acceptance review finding: an upstream 400 passed +// through by executeUsageQuery must NOT be classified as a local +// configuration problem — all-upstream-failure must yield 502, not 400. +func TestHandleManagement_AllUsage_Upstream400NotMisclassified(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"bad request from upstream"}`)) + })) + defer ts.Close() + SetHostCaller(nil) + SetDefaultHTTPClient(ts.Client()) + defer SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second}) + + cfgYAML := []byte("session_token: testtoken\napi_base: " + ts.URL + "\nopencode_api_key: sk-test\nopencode_api_base: " + ts.URL + "\n") + cfg := NewPlugin() + if err := cfg.config.UpdateFromYAML(cfgYAML); err != nil { + t.Fatalf("UpdateFromYAML: %v", err) + } + + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/all", + } + resp, err := HandleManagement(context.Background(), req, cfg.config) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("StatusCode = %d, want 502 (upstream 400 must not be misread as local missing config), body=%s", resp.StatusCode, string(resp.Body)) + } +} + +// Unknown path after the new routes still 404s. +func TestHandleManagement_UnknownPath(t *testing.T) { + req := ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/management/plugins/commandcode/nonsense", + } + resp, err := HandleManagement(context.Background(), req, nil) + if err != nil { + t.Fatalf("HandleManagement error: %v", err) + } + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("StatusCode = %d, want 404", resp.StatusCode) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go index d9c1fc4..8e22d39 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -13,7 +13,7 @@ import ( const ( PluginID = "commandcode" PluginName = "commandcode" - PluginVersion = "0.2.2" + PluginVersion = "0.3.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,9 +22,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"` + 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"` } // UpdateFromYAML updates the configuration from raw YAML bytes. @@ -33,8 +35,10 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error { return nil } var tmp struct { - SessionToken string `yaml:"session_token"` - APIBase string `yaml:"api_base"` + SessionToken string `yaml:"session_token"` + APIBase string `yaml:"api_base"` + OpenCodeAPIKey string `yaml:"opencode_api_key"` + OpenCodeAPIBase string `yaml:"opencode_api_base"` } if err := yaml.Unmarshal(raw, &tmp); err != nil { return fmt.Errorf("unmarshal config_yaml: %w", err) @@ -49,6 +53,14 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error { if tmp.APIBase != "" { c.APIBase = strings.TrimRight(tmp.APIBase, "/") } + if tmp.OpenCodeAPIKey != "" { + // OpenCode API key is a plain Bearer token; do not run it through + // ExtractSessionToken (that is Command Code cookie specific). + c.OpenCodeAPIKey = strings.TrimSpace(tmp.OpenCodeAPIKey) + } + if tmp.OpenCodeAPIBase != "" { + c.OpenCodeAPIBase = strings.TrimRight(tmp.OpenCodeAPIBase, "/") + } if c.APIBase == "" { c.APIBase = DefaultAPIBase } @@ -79,6 +91,24 @@ func (c *PluginConfig) GetAPIBase() string { return c.APIBase } +// GetOpenCodeAPIKey safely returns the OpenCode Go API key. +func (c *PluginConfig) GetOpenCodeAPIKey() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.OpenCodeAPIKey +} + +// GetOpenCodeAPIBase safely returns the OpenCode Go API base URL, +// falling back to DefaultOpenCodeAPIBase when unset. +func (c *PluginConfig) GetOpenCodeAPIBase() string { + c.mu.RLock() + defer c.mu.RUnlock() + if c.OpenCodeAPIBase == "" { + return DefaultOpenCodeAPIBase + } + return c.OpenCodeAPIBase +} + // Plugin encapsulates the Command Code plugin instance. type Plugin struct { config *PluginConfig @@ -148,6 +178,16 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) { Type: "string", Description: "Command Code API base URL (default: https://api.commandcode.ai)", }, + { + Name: "opencode_api_key", + Type: "string", + Description: "OpenCode Go API key (Bearer token used for https://opencode.ai/zen/go/v1/usage)", + }, + { + Name: "opencode_api_base", + Type: "string", + Description: "OpenCode Go API base URL (default: https://opencode.ai/zen/go/v1)", + }, }, }, Capabilities: RegistrationCapability{ diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index e1f422f..efe8af2 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -47,15 +47,15 @@ api_base: "https://custom-api.commandcode.ai" } // Verify config fields - if len(reg.Metadata.ConfigFields) != 2 { - t.Fatalf("ConfigFields len = %d, want 2", len(reg.Metadata.ConfigFields)) + if len(reg.Metadata.ConfigFields) != 4 { + t.Fatalf("ConfigFields len = %d, want 4", 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"] { - t.Errorf("ConfigFields missing session_token or api_base: %+v", reg.Metadata.ConfigFields) + if !fieldNames["session_token"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_base"] { + t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields) } // Verify config parsed @@ -65,7 +65,6 @@ api_base: "https://custom-api.commandcode.ai" if p.config.GetAPIBase() != "https://custom-api.commandcode.ai" { t.Errorf("APIBase = %q, want https://custom-api.commandcode.ai", p.config.GetAPIBase()) } - // Test plugin.reconfigure reconfYAML := []byte(` session_token: "new-token-abc" @@ -135,3 +134,39 @@ func TestEnvelopeError(t *testing.T) { t.Errorf("env.Error = %+v", env.Error) } } + +func TestPluginConfig_OpenCode(t *testing.T) { + p := NewPlugin() + configYAML := []byte("opencode_api_key: \" sk-opencode-123 \"\nopencode_api_base: \"https://custom.oc.example/v1/\"\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.GetOpenCodeAPIKey(); got != "sk-opencode-123" { + t.Errorf("OpenCodeAPIKey = %q, want sk-opencode-123", got) + } + if got := p.config.GetOpenCodeAPIBase(); got != "https://custom.oc.example/v1" { + t.Errorf("OpenCodeAPIBase = %q, want https://custom.oc.example/v1 (trailing slash trimmed)", got) + } + + // A Command Code cookie string must NOT be run through ExtractSessionToken. + cookieLike := []byte("opencode_api_key: \"sk-raw-bearer-value\"\n") + req2, _ := json.Marshal(LifecycleRequest{ConfigYAML: cookieLike}) + if _, err := p.HandleMethod("plugin.reconfigure", req2); err != nil { + t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err) + } + if got := p.config.GetOpenCodeAPIKey(); got != "sk-raw-bearer-value" { + t.Errorf("OpenCodeAPIKey = %q, want sk-raw-bearer-value (raw, no cookie extraction)", got) + } + + // Empty config falls back to the default base. + empty := NewPlugin() + if got := empty.config.GetOpenCodeAPIBase(); got != DefaultOpenCodeAPIBase { + t.Errorf("default OpenCodeAPIBase = %q, want %q", got, DefaultOpenCodeAPIBase) + } + if got := empty.config.GetOpenCodeAPIKey(); got != "" { + t.Errorf("default OpenCodeAPIKey = %q, want empty", got) + } +} diff --git a/plugin/quota_page.go b/plugin/quota_page.go index d714f35..9f0214a 100644 --- a/plugin/quota_page.go +++ b/plugin/quota_page.go @@ -10,7 +10,7 @@ const QuotaPageHTML = ` - Command Code 配额与用量 - CLIProxyAPI + 用量配额 - Command Code + OpenCode Go - CLIProxyAPI