8 Commits
Author SHA1 Message Date
yuez c2b8d89ba6 fix(quota): declare monthlyTargetTime (undeclared since v0.4.2 refactor), ReferenceError when Command Code data absent; add undeclared-identifier scan to pagecheck (v0.4.5) 2026-09-23 10:09:23 +08:00
yuez 9d1884cf20 feat(quota): All tab Command Code card shows Plan + 5h/weekly/monthly windows with reset times (v0.4.4) 2026-09-20 15:25:29 +08:00
yuez 447ce65c9b feat(quota): All tab first in tab bar; remove header usage-alert badge (v0.4.3)
- Tab order: All | Command Code | OpenCode Go (All remains the default)
- Drop the global status badge in the header action row and its
  updateGlobalBadge aggregation; per-card chips and All-tab provider
  badges are unaffected
2026-09-20 14:52:24 +08:00
yuez 4fdc9ad6c8 feat(quota): All as default tab with per-key OpenCode cards grouped by provider, bump v0.4.2 2026-09-20 14:20:01 +08:00
yuez 2f1b2fb821 build: pagecheck as standalone node script (inline escaping was fragile) 2026-09-20 13:18:21 +08:00
yuez 3097d96a8e chore: bump v0.4.1 to force plugin re-sync with fixed page (v0.4.0 asset shipped with broken JS) 2026-09-20 13:00:35 +08:00
yuez 0a9213ec38 fix(quota): remove duplicate const lastUpdated declaration that broke JS parse
The v0.4.0 UI refactor left two top-level const lastUpdated declarations;
a SyntaxError at script parse time took down the whole quota page.
Also added a node --check based syntax verification to the release flow.
2026-09-20 12:59:46 +08:00
yuez 553226c96e feat: multi-key OpenCode Go usage + quota page redesign, bump v0.4.0
- support multiple OpenCode API keys for Go usage and quota queries
- redesigned quota page UI
- update README and tests
2026-09-20 10:50:06 +08:00
11 changed files with 1380 additions and 376 deletions
+9 -2
View File
@@ -7,15 +7,22 @@ else
TARGET := commandcode.so TARGET := commandcode.so
endif endif
.PHONY: all build test clean lint .PHONY: all build test clean lint pagecheck
all: build all: build pagecheck
build: build:
CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go
# Extract embedded JS from quota_page.go and syntax-check it with node.
# Guards against parse-time SyntaxErrors (e.g. duplicate const) that break
# the whole resource page; Go substring tests cannot catch these.
pagecheck:
@node scripts/pagecheck.js
test: test:
go test -v -race ./... go test -v -race ./...
$(MAKE) pagecheck
clean: clean:
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
+45 -21
View File
@@ -134,8 +134,11 @@ plugins:
priority: 1 priority: 1
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串 session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口 api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # v0.3.0+ 可选,OpenCode Go 用量查询 opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+)
opencode_api_base: "https://opencode.ai/zen/go/v1" # 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) - **端点**:`GET /v0/management/plugins/commandcode/opencode/usage`(认证同上,仅读插件配置;凭据覆盖走 POST)
- **端点**:`POST /v0/management/plugins/commandcode/opencode/usage` - **端点**:`POST /v0/management/plugins/commandcode/opencode/usage`
- **POST 请求体**: - **POST 请求体**(多 key 数组优先;scalar 为 v0.3.0 兼容):
```json ```json
{ "opencode_api_key": "sk-YOUR_TEMPORARY_KEY" } { "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] }
``` ```
- **响应示例**: - **响应(v0.4.0+,逐 key 结果数组)**:
```json ```json
{ {
"ok": true, "ok": true,
"provider": "opencode_go", "provider": "opencode_go",
"windows": { "keys": [
"rolling": { "status": "ok", "percent": 4, "exceeded": false, {
"reset_at": "2026-09-17T06:58:53Z", "reset_in_seconds": 2520 }, "key_id": "sk-L…KqYB",
"weekly": { "status": "ok", "percent": 46, "exceeded": false, "ok": true,
"reset_at": "2026-09-21T00:00:00Z", "reset_in_seconds": 259200 }, "windows": {
"monthly": { "status": "ok", "percent": 23, "exceeded": false, "rolling": { "status": "ok", "percent": 4, "exceeded": false,
"reset_at": "2026-10-14T09:13:49Z", "reset_in_seconds": 1728000 } "reset_at": "2026-09-17T06:58:53Z", "reset_in_seconds": 2520 },
}, "weekly": { "status": "ok", "percent": 46, "exceeded": false,
"updated_at": "2026-09-16T12:00:00Z" "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`) ### 5. 管理 API: 聚合查询 (`all`)
- **端点**:`GET /v0/management/plugins/commandcode/all`(仅读插件配置) - **端点**:`GET /v0/management/plugins/commandcode/all`(仅读插件配置)
- **端点**:`POST /v0/management/plugins/commandcode/all` - **端点**:`POST /v0/management/plugins/commandcode/all`
- **POST 请求体**(可只带其一): - **POST 请求体**(可只带其一;多 key 覆盖为数组):
```json ```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 ```json
{ {
"ok": true, "ok": true,
"commandcode": { "ok": true, "plan": {...}, "credits": {...}, "window_limits": {...}, "updated_at": "..." }, "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" "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.five_hour.reset_in_seconds`| `int64` | 距离 5 小时窗口重置的剩余秒数 |
| `window_limits.weekly.*` | - | 每周限额对应指标(结构同 5 小时窗口) | | `window_limits.weekly.*` | - | 每周限额对应指标(结构同 5 小时窗口) |
| `windows.<rolling\|weekly\|monthly>.status` | `string` | OpenCode Go 窗口状态(`"ok"`/上游其他值,未知值不报错) | | `windows.<rolling\|weekly\|monthly>.status` | `string` | OpenCode Go 窗口状态(`"ok"`/上游其他值,未知值不报错) |
| `windows.<...>.percent` | `float64` | OpenCode Go 窗口使用百分比(0-100,钳制) | | `keys[].key_id` | `string` | 服务端脱敏 key 标识(前4+…+后4),原始 key 不出响应 |
| `windows.<...>.exceeded` | `bool` | `percent >= 100` 或上游 `status == "exceeded"` | | `keys[].ok` | `bool` | 该 key 查询是否成功(单 key 401 隔离) |
| `windows.<...>.reset_at` / `reset_in_seconds` | `string` / `int64` | OpenCode Go 窗口重置时间(解析失败优雅降级为空/0) | | `keys[].windows.<...>` | `object` | 成功 key 的三窗口指标(结构同上;失败 key 无此字段) |
| `keys[].status_code` / `error` | `int` / `string` | 该 key 上游 HTTP 状态与失败原因 |
--- ---
+139 -98
View File
@@ -273,36 +273,91 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
} }
// handleOpenCodeUsage serves GET/POST /plugins/commandcode/opencode/usage. // handleOpenCodeUsage serves GET/POST /plugins/commandcode/opencode/usage.
// Credentials can be overridden via POST body only (opencode_api_key / api_key); // Credentials can be overridden via POST body only (opencode_api_keys list /
// GET queries are read-only against the plugin config — query parameter // opencode_api_key scalar); GET queries are read-only against the plugin
// overrides are intentionally not supported to keep secrets out of URLs. // 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) { func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
apiKey := ""
apiBase := "" 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 { var body struct {
OpenCodeAPIKey string `json:"opencode_api_key"` OpenCodeAPIKeys []string `json:"opencode_api_keys"`
APIKey string `json:"api_key"` OpenCodeAPIKey string `json:"opencode_api_key"`
OpenCodeAPIBase string `json:"opencode_api_base"` APIKey string `json:"api_key"`
OpenCodeAPIBase string `json:"opencode_api_base"`
} }
_ = json.Unmarshal(req.Body, &body) _ = json.Unmarshal(req.Body, &body)
apiKey = body.OpenCodeAPIKey keys = normalizeOpenCodeKeys(body.OpenCodeAPIKeys)
if apiKey == "" { if len(keys) == 0 {
apiKey = body.APIKey single := strings.TrimSpace(body.OpenCodeAPIKey)
if single == "" {
single = strings.TrimSpace(body.APIKey)
}
if single != "" {
keys = []string{single}
}
} }
apiBase = body.OpenCodeAPIBase apiBase = body.OpenCodeAPIBase
} }
// Fallback to plugin config // Fallback to plugin config
if apiKey == "" && cfg != nil { if len(keys) == 0 && cfg != nil {
apiKey = cfg.GetOpenCodeAPIKey() keys = cfg.GetOpenCodeAPIKeys()
} }
if apiBase == "" && cfg != nil { if apiBase == "" && cfg != nil {
apiBase = cfg.GetOpenCodeAPIBase() 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 // 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. // all failed due to upstream errors → 502.
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) { func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
sessionToken := "" 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 { var body struct {
SessionToken string `json:"session_token"` SessionToken string `json:"session_token"`
OpencodeAPIKey string `json:"opencode_api_key"` OpencodeAPIKeys []string `json:"opencode_api_keys"`
OpencodeAPIKey string `json:"opencode_api_key"`
} }
_ = json.Unmarshal(req.Body, &body) _ = json.Unmarshal(req.Body, &body)
sessionToken = body.SessionToken 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 // Fallback to plugin config
if sessionToken == "" && cfg != nil { if sessionToken == "" && cfg != nil {
sessionToken = cfg.GetSessionToken() sessionToken = cfg.GetSessionToken()
} }
if opencodeKey == "" && cfg != nil { if len(opencodeKeys) == 0 && cfg != nil {
opencodeKey = cfg.GetOpenCodeAPIKey() opencodeKeys = cfg.GetOpenCodeAPIKeys()
} }
apiBase := "" apiBase := ""
if cfg != nil { if cfg != nil {
@@ -364,25 +425,34 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
} }
} }
// Provider 2: OpenCode Go (same classification via isLocalCredentialError, // Provider 2: OpenCode Go, one sequential query per configured key
// not by HTTP 400 alone: upstream 4xx may be passed through and must not // (v0.4.0). >=1 key success counts the provider as successful and the
// be misclassified as a local configuration problem). // multi-key payload is inlined; keys configured but all failed is an
if strings.TrimSpace(opencodeKey) != "" { // upstream failure (a configured-but-invalid key is NOT a local config
ocResp, _ := handleOpenCodeUsageWithKey(ctx, ocAPIBase, opencodeKey, req.HostCallbackID) // problem); zero keys configured is a local missing-credential error.
if ocResp.StatusCode == http.StatusOK { if len(opencodeKeys) > 0 {
resp.OpenCode = ocResp.Body results := QueryOpenCodeKeys(ctx, ocAPIBase, opencodeKeys, req.HostCallbackID)
succeeded++ succeededKeys := 0
} else { for _, r := range results {
ocErr := extractErrorResponseMessage(ocResp.Body) if r.OK {
errs["opencode"] = ocErr succeededKeys++
if isLocalCredentialError(ocErr) {
localMissing++
} else {
upstreamFailed++
} }
} }
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 { } 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++ localMissing++
} }
@@ -419,6 +489,7 @@ func isLocalCredentialError(msg string) bool {
for _, prefix := range []string{ for _, prefix := range []string{
"session_token is required", "session_token is required",
"opencode_api_key is required", "opencode_api_key is required",
"no opencode api keys configured",
} { } {
if strings.HasPrefix(msg, prefix) { if strings.HasPrefix(msg, prefix) {
return true return true
@@ -427,81 +498,51 @@ func isLocalCredentialError(msg string) bool {
return false return false
} }
// handleOpenCodeUsageWithKey runs the OpenCode usage query with an explicit // queryOpenCodeKey runs the OpenCode Go usage query for a single API key and
// credential, shared by handleOpenCodeUsage and handleAllUsage. // returns a typed per-key result, shared by handleOpenCodeUsage and
func handleOpenCodeUsageWithKey(ctx context.Context, apiBase, apiKey, hostCallbackID string) (ManagementResponse, error) { // handleAllUsage (via QueryOpenCodeKeys). The handler layer is responsible
if strings.TrimSpace(apiKey) == "" { // for marshaling the aggregate response and picking the HTTP status code.
resBytes, _ := json.Marshal(map[string]any{ func queryOpenCodeKey(ctx context.Context, apiBase, key, hostCallbackID string) (OpenCodeKeyResult, error) {
"ok": false, now := time.Now().UTC()
"error": "opencode_api_key is required. Configure opencode_api_key in plugin config or pass it in the request body", res := OpenCodeKeyResult{
}) KeyID: MaskAPIKey(key),
return ManagementResponse{ UpdatedAt: now.Format(time.RFC3339),
StatusCode: http.StatusBadRequest,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
} }
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 { 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 { if statusCode == 0 || statusCode == http.StatusOK {
statusCode = http.StatusBadGateway statusCode = http.StatusBadGateway
} }
return ManagementResponse{ res.StatusCode = statusCode
StatusCode: statusCode, res.Error = fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error())
Headers: map[string][]string{ return res, nil
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
resBytes, _ := json.Marshal(map[string]any{ res.StatusCode = statusCode
"ok": false, res.Error = fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode)
"status_code": statusCode, return res, nil
"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()) usage, errParse := ParseOpenCodeUsage(raw, now)
if errParse != nil { if errParse != nil {
resBytes, _ := json.Marshal(map[string]any{ res.StatusCode = http.StatusBadGateway
"ok": false, res.Error = "failed to parse opencode upstream usage: " + errParse.Error()
"error": "failed to parse opencode upstream usage: " + errParse.Error(), return res, nil
})
return ManagementResponse{
StatusCode: http.StatusBadGateway,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
} }
resBytes, _ := json.Marshal(usage) res.OK = true
return ManagementResponse{ res.StatusCode = http.StatusOK
StatusCode: http.StatusOK, res.Windows = &usage.Windows
Headers: map[string][]string{ res.UpdatedAt = usage.UpdatedAt
"Content-Type": {"application/json"}, return res, nil
},
Body: resBytes,
}, nil
} }
// extractErrorResponseMessage pulls the "error" field out of a JSON error body. // extractErrorResponseMessage pulls the "error" field out of a JSON error body.
+366 -10
View File
@@ -3,6 +3,7 @@ package plugin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -74,6 +75,9 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
if !strings.Contains(bodyStr, "用量配额") { if !strings.Contains(bodyStr, "用量配额") {
t.Errorf("Body does not contain expected menu text 用量配额") 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")
}
} }
} }
@@ -172,6 +176,24 @@ const mockOpencodeUsageJSON = `{"usage":{
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"} "monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
}}` }}`
// mockOpencodeUsageJSONFuture returns the same usage envelope but with reset
// timestamps relative to now. The hardcoded dates in mockOpencodeUsageJSON
// eventually fall into the past (weekly 2026-09-21 did), which makes
// weekly.ResetInSeconds = 0 and breaks the `want > 0` assertion in
// TestHandleManagement_OpencodeUsageRoute. Use this fixture for tests that
// assert positive reset_in_seconds.
func mockOpencodeUsageJSONFuture() string {
now := time.Now().UTC()
ts := func(d time.Duration) string {
return now.Add(d).Format("2006-01-02T15:04:05.000Z")
}
return fmt.Sprintf(`{"usage":{
"rolling": {"status":"ok","percent":4, "resetsAt":"%s"},
"weekly": {"status":"ok","percent":46,"resetsAt":"%s"},
"monthly": {"status":"ok","percent":23,"resetsAt":"%s"}
}}`, ts(2*time.Hour), ts(72*time.Hour), ts(30*24*time.Hour))
}
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated // Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match // OpenCode handler and NOT swallowed by the generic "/usage" suffix match
// (which would route it to the Command Code handler). // (which would route it to the Command Code handler).
@@ -191,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
} }
sawAuthHeader = true sawAuthHeader = true
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockOpencodeUsageJSON)) _, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
})) }))
defer ts.Close() defer ts.Close()
@@ -230,19 +252,30 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body)) 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 { if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal body error: %v", err) t.Fatalf("unmarshal body error: %v", err)
} }
if !usage.OK || usage.Provider != "opencode_go" { if !usage.OK || usage.Provider != "opencode_go" {
t.Fatalf("unexpected response: ok=%v provider=%q", usage.OK, usage.Provider) 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 { if len(usage.Keys) != 1 || !usage.Keys[0].OK {
t.Errorf("windows percents = %v/%v/%v, want 4/46/23", t.Fatalf("expected exactly one successful key, got %+v", usage.Keys)
usage.Windows.Rolling.Percent, usage.Windows.Weekly.Percent, usage.Windows.Monthly.Percent)
} }
if usage.Windows.Weekly.ResetInSeconds <= 0 { if usage.Keys[0].Windows == nil {
t.Errorf("weekly reset_in_seconds = %d, want > 0", usage.Windows.Weekly.ResetInSeconds) 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 +285,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 // Regression: /plugins/commandcode/all must not be swallowed by the generic
// "/usage" suffix match nor miss its dedicated handler. // "/usage" suffix match nor miss its dedicated handler.
func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) { func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) {
@@ -314,10 +516,14 @@ func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) {
if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK { if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK {
t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage) 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 { if err := json.Unmarshal(all.OpenCode, &ocUsage); err != nil || !ocUsage.OK {
t.Errorf("opencode payload invalid: err=%v usage=%+v", err, ocUsage) 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 // Partial failure: one provider fails upstream → ok stays true, the failed
@@ -377,8 +583,10 @@ func TestHandleManagement_AllUsage_PartialFailure(t *testing.T) {
if _, present := all.Errors["opencode"]; !present { if _, present := all.Errors["opencode"]; !present {
t.Errorf("expected errors[opencode] to be set, got %v", all.Errors) t.Errorf("expected errors[opencode] to be set, got %v", all.Errors)
} }
if !strings.Contains(all.Errors["opencode"], "opencode upstream returned 500") { // v0.4.0: a configured-but-failed key is an upstream failure; with the
t.Errorf("errors[opencode] = %q, want it to mention 'opencode upstream returned 500'", all.Errors["opencode"]) // 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". // opencode field must be omitted (omitempty), not serialized as "null".
if strings.Contains(string(resp.Body), `"opencode":null`) { if strings.Contains(string(resp.Body), `"opencode":null`) {
@@ -450,6 +658,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. // Unknown path after the new routes still 404s.
func TestHandleManagement_UnknownPath(t *testing.T) { func TestHandleManagement_UnknownPath(t *testing.T) {
req := ManagementRequest{ req := ManagementRequest{
+62 -11
View File
@@ -13,7 +13,7 @@ import (
const ( const (
PluginID = "commandcode" PluginID = "commandcode"
PluginName = "commandcode" PluginName = "commandcode"
PluginVersion = "0.3.0" PluginVersion = "0.4.5"
PluginAuthor = "zgs225" PluginAuthor = "zgs225"
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode" PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg" 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. // PluginConfig holds the runtime configuration parsed from YAML.
type PluginConfig struct { type PluginConfig struct {
mu sync.RWMutex mu sync.RWMutex
SessionToken string `yaml:"session_token" json:"session_token"` SessionToken string `yaml:"session_token" json:"session_token"`
APIBase string `yaml:"api_base" json:"api_base"` APIBase string `yaml:"api_base" json:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"` OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"` 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. // UpdateFromYAML updates the configuration from raw YAML bytes.
@@ -35,10 +36,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
return nil return nil
} }
var tmp struct { var tmp struct {
SessionToken string `yaml:"session_token"` SessionToken string `yaml:"session_token"`
APIBase string `yaml:"api_base"` APIBase string `yaml:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key"` OpenCodeAPIKey string `yaml:"opencode_api_key"`
OpenCodeAPIBase string `yaml:"opencode_api_base"` OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
OpenCodeAPIBase string `yaml:"opencode_api_base"`
} }
if err := yaml.Unmarshal(raw, &tmp); err != nil { if err := yaml.Unmarshal(raw, &tmp); err != nil {
return fmt.Errorf("unmarshal config_yaml: %w", err) 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). // ExtractSessionToken (that is Command Code cookie specific).
c.OpenCodeAPIKey = strings.TrimSpace(tmp.OpenCodeAPIKey) 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 != "" { if tmp.OpenCodeAPIBase != "" {
c.OpenCodeAPIBase = strings.TrimRight(tmp.OpenCodeAPIBase, "/") c.OpenCodeAPIBase = strings.TrimRight(tmp.OpenCodeAPIBase, "/")
} }
@@ -91,13 +102,48 @@ func (c *PluginConfig) GetAPIBase() string {
return c.APIBase 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 { func (c *PluginConfig) GetOpenCodeAPIKey() string {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.OpenCodeAPIKey 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, // GetOpenCodeAPIBase safely returns the OpenCode Go API base URL,
// falling back to DefaultOpenCodeAPIBase when unset. // falling back to DefaultOpenCodeAPIBase when unset.
func (c *PluginConfig) GetOpenCodeAPIBase() string { func (c *PluginConfig) GetOpenCodeAPIBase() string {
@@ -181,7 +227,12 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
{ {
Name: "opencode_api_key", Name: "opencode_api_key",
Type: "string", 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", Name: "opencode_api_base",
+90 -4
View File
@@ -46,15 +46,15 @@ api_base: "https://custom-api.commandcode.ai"
t.Errorf("Capabilities.ManagementAPI = false, want true") t.Errorf("Capabilities.ManagementAPI = false, want true")
} }
// Verify config fields // Verify config fields (v0.4.0: 4 → 5, adds opencode_api_keys)
if len(reg.Metadata.ConfigFields) != 4 { if len(reg.Metadata.ConfigFields) != 5 {
t.Fatalf("ConfigFields len = %d, want 4", len(reg.Metadata.ConfigFields)) t.Fatalf("ConfigFields len = %d, want 5", len(reg.Metadata.ConfigFields))
} }
fieldNames := map[string]bool{} fieldNames := map[string]bool{}
for _, f := range reg.Metadata.ConfigFields { for _, f := range reg.Metadata.ConfigFields {
fieldNames[f.Name] = true 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) 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 != "" { if got := empty.config.GetOpenCodeAPIKey(); got != "" {
t.Errorf("default OpenCodeAPIKey = %q, want empty", 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])
}
})
} }
+296 -228
View File
@@ -10,7 +10,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用量配额 - Command Code + OpenCode Go - CLIProxyAPI</title> <title>用量配额 - CLIProxyAPI</title>
<style> <style>
:root { :root {
--bg-page: #f8fafc; --bg-page: #f8fafc;
@@ -217,11 +217,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
} }
.brand-subtitle {
font-size: 13px;
color: var(--text-muted);
}
.action-group { .action-group {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -666,12 +661,110 @@ const QuotaPageHTML = `<!DOCTYPE html>
display: block; display: block;
} }
/* OpenCode card status chip (reuses .status-badge variants) */ /* Command Code tab head: title + plan badge (planBadge moved out of header) */
.oc-status-wrap { .cc-tab-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 10px;
}
.cc-tab-title {
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
/* OpenCode multi-key groups: one group per key, 3 compact window rows each */
.oc-key-group {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; gap: 12px;
gap: 6px; margin-bottom: 16px;
}
.oc-key-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.oc-key-id {
font-size: 13px;
font-family: monospace;
color: var(--text-muted);
}
.oc-key-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.oc-win-row {
display: grid;
grid-template-columns: 92px 1fr 52px 84px;
align-items: center;
gap: 12px;
}
@media (max-width: 640px) {
.oc-win-row {
grid-template-columns: 86px 1fr 48px;
}
.oc-win-reset {
display: none;
}
}
.oc-win-name {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
/* .progress-track combo class: 8px mini bar, same as .all-grid */
.oc-win-bar {
height: 8px;
}
.oc-win-pct {
font-size: 13px;
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
font-feature-settings: "tnum";
}
.oc-win-pct.warn { color: var(--warning); }
.oc-win-pct.bad { color: var(--danger); }
/* .countdown-timer combo class: smaller reset countdown */
.oc-win-reset {
font-size: 12px;
}
.oc-key-error {
font-size: 12px;
color: var(--danger);
word-break: break-all;
}
.all-key-id {
font-family: monospace;
font-size: 12px;
}
textarea.form-control {
resize: vertical;
} }
/* All tab: two provider cards side by side */ /* All tab: two provider cards side by side */
@@ -688,6 +781,17 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
} }
.all-group-title {
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
margin: 8px 0 10px 2px;
}
.all-group-title:first-child {
margin-top: 0;
}
.all-provider-card { .all-provider-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -813,20 +917,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
<div> <div>
<div class="brand-title"> <div class="brand-title">
用量配额 - Command Code + OpenCode Go 用量配额
<span class="version-tag">v0.3.0</span> <span class="version-tag">v0.4.5</span>
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
</div> </div>
<div class="brand-subtitle">CLIProxyAPI 实时限额与用量监控 (Command Code + OpenCode Go)</div>
</div> </div>
</div> </div>
<div class="action-group"> <div class="action-group">
<div id="statusBadge" class="status-badge">
<span class="status-dot"></span>
<span id="statusText">正在检查...</span>
</div>
<button id="btnSettings" class="btn" title="配置选项"> <button id="btnSettings" class="btn" title="配置选项">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
@@ -847,9 +944,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
<!-- Tab Bar --> <!-- Tab Bar -->
<div id="tabBar" class="tab-bar"> <div id="tabBar" class="tab-bar">
<button type="button" class="tab-btn active" data-tab="commandcode">Command Code</button> <button type="button" class="tab-btn active" data-tab="all">All</button>
<button type="button" class="tab-btn" data-tab="commandcode">Command Code</button>
<button type="button" class="tab-btn" data-tab="opencode">OpenCode Go</button> <button type="button" class="tab-btn" data-tab="opencode">OpenCode Go</button>
<button type="button" class="tab-btn" data-tab="all">All</button>
</div> </div>
<!-- Alert Message --> <!-- Alert Message -->
@@ -874,9 +971,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
<input type="password" id="inputSessionToken" class="form-control" placeholder="覆盖测试: __Secure-commandcode_prod_.session_token" /> <input type="password" id="inputSessionToken" class="form-control" placeholder="覆盖测试: __Secure-commandcode_prod_.session_token" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="inputOpenCodeKey">OpenCode Go API Key (测试覆盖)</label> <label for="inputOpenCodeKeys">OpenCode Go API Keys (测试覆盖)</label>
<input type="password" id="inputOpenCodeKey" class="form-control" placeholder="覆盖测试: sk-..." /> <textarea id="inputOpenCodeKeys" class="form-control" rows="3" placeholder="每行一个 sk-..."></textarea>
<span class="form-hint">仅测试覆盖用:值不持久化,仅当次请求生效</span> <span class="form-hint">每行一个 key;仅当次请求生效,不持久化</span>
</div> </div>
</div> </div>
<div style="margin-top: 14px; display: flex; justify-content: flex-end; gap: 10px;"> <div style="margin-top: 14px; display: flex; justify-content: flex-end; gap: 10px;">
@@ -885,7 +982,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
<!-- Tab: Command Code --> <!-- Tab: Command Code -->
<div id="sectionCommandcode" class="tab-section active"> <div id="sectionCommandcode" class="tab-section">
<div id="ccErrorCard" class="error-card"> <div id="ccErrorCard" class="error-card">
<div class="error-card-title"> <div class="error-card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
@@ -895,6 +992,10 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
<div id="ccContent"> <div id="ccContent">
<div class="cc-tab-head">
<span class="cc-tab-title">Command Code</span>
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
</div>
<!-- Overview Metrics --> <!-- Overview Metrics -->
<div class="metrics-grid"> <div class="metrics-grid">
@@ -1035,108 +1136,27 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
<div id="ocErrorMsg" class="error-card-msg">-</div> <div id="ocErrorMsg" class="error-card-msg">-</div>
</div> </div>
<div id="ocContent"> <div id="ocContent"></div>
<div class="metrics-grid">
<!-- Rolling 5h Window -->
<div id="ocCardRolling" class="quota-card">
<div class="quota-card-header">
<div>
<span class="quota-tag">短期滑动窗口</span>
<div class="quota-name">Rolling 5h (5 小时窗口)</div>
</div>
<div class="oc-status-wrap">
<span id="ocStatusRolling" class="status-badge online"><span class="status-dot"></span><span id="ocStatusTextRolling">-</span></span>
<div id="ocBadgeRolling" class="quota-percent-badge">- %</div>
</div>
</div>
<div class="progress-track">
<div id="ocBarRolling" class="progress-bar"></div>
</div>
<div class="reset-box">
<div class="reset-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
<span>重置倒计时</span>
</div>
<div id="ocTimerRolling" class="countdown-timer">-</div>
</div>
</div>
<!-- Weekly Window -->
<div id="ocCardWeekly" class="quota-card">
<div class="quota-card-header">
<div>
<span class="quota-tag" style="color:#8b5cf6; background:rgba(139,92,246,0.12)">周度窗口</span>
<div class="quota-name">Weekly (每周限制)</div>
</div>
<div class="oc-status-wrap">
<span id="ocStatusWeekly" class="status-badge online"><span class="status-dot"></span><span id="ocStatusTextWeekly">-</span></span>
<div id="ocBadgeWeekly" class="quota-percent-badge">- %</div>
</div>
</div>
<div class="progress-track">
<div id="ocBarWeekly" class="progress-bar"></div>
</div>
<div class="reset-box">
<div class="reset-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
<span>重置倒计时</span>
</div>
<div id="ocTimerWeekly" class="countdown-timer">-</div>
</div>
</div>
<!-- Monthly Window -->
<div id="ocCardMonthly" class="quota-card">
<div class="quota-card-header">
<div>
<span class="quota-tag" style="color:#f59e0b; background:rgba(245,158,11,0.12)">月度窗口</span>
<div class="quota-name">Monthly (月度窗口)</div>
</div>
<div class="oc-status-wrap">
<span id="ocStatusMonthly" class="status-badge online"><span class="status-dot"></span><span id="ocStatusTextMonthly">-</span></span>
<div id="ocBadgeMonthly" class="quota-percent-badge">- %</div>
</div>
</div>
<div class="progress-track">
<div id="ocBarMonthly" class="progress-bar"></div>
</div>
<div class="reset-box">
<div class="reset-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
<span>重置倒计时</span>
</div>
<div id="ocTimerMonthly" class="countdown-timer">-</div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- /Tab: OpenCode Go --> <!-- /Tab: OpenCode Go -->
<!-- Tab: All --> <!-- Tab: All -->
<div id="sectionAll" class="tab-section"> <div id="sectionAll" class="tab-section active">
<div class="all-grid"> <!-- All tab: vertical groups, one group title per provider -->
<div class="all-provider-card"> <div class="all-group-title">Command Code</div>
<div class="all-provider-head"> <div class="all-provider-card">
<span class="all-provider-name">Command Code</span> <div class="all-provider-head">
<span id="allBadgeCommandcode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextCommandcode">-</span></span> <span id="allBadgeCommandcode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextCommandcode">-</span></span>
</div>
<div id="allBodyCommandcode" class="all-provider-body">尚未加载</div>
</div> </div>
<div id="allBodyCommandcode" class="all-provider-body">尚未加载</div>
</div>
<div class="all-provider-card"> <div class="all-group-title">OpenCode Go</div>
<div class="all-provider-head"> <div class="all-provider-card">
<span class="all-provider-name">OpenCode Go</span> <div class="all-provider-head">
<span id="allBadgeOpencode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextOpencode">-</span></span> <span id="allBadgeOpencode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextOpencode">-</span></span>
</div>
<div id="allBodyOpencode" class="all-provider-body">尚未加载</div>
</div> </div>
<div id="allBodyOpencode" class="all-provider-body">尚未加载</div>
</div> </div>
</div> </div>
<!-- /Tab: All --> <!-- /Tab: All -->
@@ -1163,11 +1183,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
const settingsDrawer = document.getElementById("settingsDrawer"); const settingsDrawer = document.getElementById("settingsDrawer");
const inputMgmtKey = document.getElementById("inputMgmtKey"); const inputMgmtKey = document.getElementById("inputMgmtKey");
const inputSessionToken = document.getElementById("inputSessionToken"); const inputSessionToken = document.getElementById("inputSessionToken");
const inputOpenCodeKey = document.getElementById("inputOpenCodeKey"); const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys");
const alertBox = document.getElementById("alertBox"); const alertBox = document.getElementById("alertBox");
const alertMsg = document.getElementById("alertMsg"); const alertMsg = document.getElementById("alertMsg");
const statusBadge = document.getElementById("statusBadge");
const statusText = document.getElementById("statusText");
const planBadge = document.getElementById("planBadge"); const planBadge = document.getElementById("planBadge");
// Tab sections and per-provider error cards // Tab sections and per-provider error cards
@@ -1215,32 +1233,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
const timerWeekly = document.getElementById("timerWeekly"); const timerWeekly = document.getElementById("timerWeekly");
const lastUpdated = document.getElementById("lastUpdated"); const lastUpdated = document.getElementById("lastUpdated");
// OpenCode Go card elements
const ocCardRolling = document.getElementById("ocCardRolling");
const ocBadgeRolling = document.getElementById("ocBadgeRolling");
const ocBarRolling = document.getElementById("ocBarRolling");
const ocTimerRolling = document.getElementById("ocTimerRolling");
const ocStatusRolling = document.getElementById("ocStatusRolling");
const ocStatusTextRolling = document.getElementById("ocStatusTextRolling");
const ocCardWeekly = document.getElementById("ocCardWeekly");
const ocBadgeWeekly = document.getElementById("ocBadgeWeekly");
const ocBarWeekly = document.getElementById("ocBarWeekly");
const ocTimerWeekly = document.getElementById("ocTimerWeekly");
const ocStatusWeekly = document.getElementById("ocStatusWeekly");
const ocStatusTextWeekly = document.getElementById("ocStatusTextWeekly");
const ocCardMonthly = document.getElementById("ocCardMonthly");
const ocBadgeMonthly = document.getElementById("ocBadgeMonthly");
const ocBarMonthly = document.getElementById("ocBarMonthly");
const ocTimerMonthly = document.getElementById("ocTimerMonthly");
const ocStatusMonthly = document.getElementById("ocStatusMonthly");
const ocStatusTextMonthly = document.getElementById("ocStatusTextMonthly");
const ocTimerEls = { rolling: ocTimerRolling, weekly: ocTimerWeekly, monthly: ocTimerMonthly };
let monthlyTargetTime = null;
let fiveHourTargetTime = null; let fiveHourTargetTime = null;
let monthlyTargetTime = null;
let weeklyTargetTime = null; let weeklyTargetTime = null;
let timerInterval = null; let timerInterval = null;
@@ -1249,8 +1244,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
commandcode: { level: "unknown", err: null, data: null }, commandcode: { level: "unknown", err: null, data: null },
opencode: { level: "unknown", err: null, data: null } opencode: { level: "unknown", err: null, data: null }
}; };
let activeTab = "commandcode"; let activeTab = "all";
let ocTargets = { rolling: null, weekly: null, monthly: null };
function getStoredManagementKey() { function getStoredManagementKey() {
if (inputMgmtKey.value.trim()) { if (inputMgmtKey.value.trim()) {
@@ -1346,26 +1340,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
return level === "error" ? "exceeded" : level === "unknown" ? "" : level; return level === "error" ? "exceeded" : level === "unknown" ? "" : level;
} }
function updateGlobalBadge() {
let p;
if (activeTab === "commandcode") {
p = providerState.commandcode;
} else if (activeTab === "opencode") {
p = providerState.opencode;
} else {
const a = providerState.commandcode;
const b = providerState.opencode;
p = LEVEL_RANK[a.level] >= LEVEL_RANK[b.level] ? a : b;
}
const visual = badgeVisualClass(p.level);
statusBadge.className = visual ? "status-badge " + visual : "status-badge";
statusText.textContent = BADGE_TEXT[p.level] || "正在检查...";
}
function setProviderStatus(provider, level) { function setProviderStatus(provider, level) {
providerState[provider].level = level; providerState[provider].level = level;
providerState[provider].err = null; providerState[provider].err = null;
updateGlobalBadge();
} }
function summaryRow(label, value) { function summaryRow(label, value) {
@@ -1391,9 +1368,11 @@ const QuotaPageHTML = `<!DOCTYPE html>
ocErrorMsg.textContent = msg; ocErrorMsg.textContent = msg;
ocErrorCard.classList.add("show"); ocErrorCard.classList.add("show");
} }
updateGlobalBadge();
} }
// Multi-window countdown elements carry their reset info in data
// attributes (data-reset-at ISO string, or data-reset-secs seconds),
// so updateTimers() can walk every tab容器's .oc-win-reset uniformly
function updateTimers() { function updateTimers() {
if (monthlyTargetTime) { if (monthlyTargetTime) {
timerMonthly.textContent = formatCountdown(monthlyTargetTime); timerMonthly.textContent = formatCountdown(monthlyTargetTime);
@@ -1404,10 +1383,20 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (weeklyTargetTime) { if (weeklyTargetTime) {
timerWeekly.textContent = formatCountdown(weeklyTargetTime); timerWeekly.textContent = formatCountdown(weeklyTargetTime);
} }
for (const key in ocTargets) { const allResetEls = document.querySelectorAll(".oc-win-reset");
if (ocTargets[key]) { for (let i = 0; i < allResetEls.length; i++) {
ocTimerEls[key].textContent = formatCountdown(ocTargets[key]); const el = allResetEls[i];
let target = null;
const at = el.getAttribute("data-reset-at");
if (at) {
const t = new Date(at);
if (!isNaN(t.getTime())) target = t;
} }
if (!target) {
const secs = Number(el.getAttribute("data-reset-secs"));
if (secs > 0) target = new Date(Date.now() + secs * 1000);
}
el.textContent = target ? formatCountdown(target) : "-";
} }
} }
@@ -1488,7 +1477,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
weeklyTargetTime = null; weeklyTargetTime = null;
} }
// Per-provider status; the header badge is aggregated in updateGlobalBadge() // Per-provider status; consumed by All tab provider badges
let ccLevel; let ccLevel;
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) { if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
ccLevel = "exceeded"; ccLevel = "exceeded";
@@ -1501,50 +1490,107 @@ const QuotaPageHTML = `<!DOCTYPE html>
updateTimers(); updateTimers();
} }
function renderOpencode(data) { function renderOpencode(data) {
providerState.opencode.err = null; providerState.opencode.err = null;
providerState.opencode.data = data; providerState.opencode.data = data;
// 多 key 契约:keys[] 为空/缺失视为 provider 级失败,走全局错误卡片
const keys = data && Array.isArray(data.keys) ? data.keys : [];
if (keys.length === 0) {
showProviderError("opencode", (data && data.error) || "OpenCode 未返回任何 key 数据");
return;
}
ocContent.style.display = ""; ocContent.style.display = "";
ocErrorCard.classList.remove("show"); ocErrorCard.classList.remove("show");
const windows = data.windows || {}; // All tab 的 OpenCode 组与本 tab 共用同一套逐 key 渲染逻辑
const defs = [ const rendered = renderOpenCodeKeyGroups(keys);
{ key: "rolling", card: ocCardRolling, badge: ocBadgeRolling, bar: ocBarRolling, timer: ocTimerRolling, chip: ocStatusRolling, chipText: ocStatusTextRolling, w: windows.rolling }, ocContent.innerHTML = rendered.html;
{ key: "weekly", card: ocCardWeekly, badge: ocBadgeWeekly, bar: ocBarWeekly, timer: ocTimerWeekly, chip: ocStatusWeekly, chipText: ocStatusTextWeekly, w: windows.weekly }, updateTimers();
{ key: "monthly", card: ocCardMonthly, badge: ocBadgeMonthly, bar: ocBarMonthly, timer: ocTimerMonthly, chip: ocStatusMonthly, chipText: ocStatusTextMonthly, w: windows.monthly }
setProviderStatus("opencode", rendered.worst);
}
// Shared per-key group renderer: the OpenCode tab and the All tab's
// OpenCode group both consume this to avoid logic drift. Returns
// { html, worst }. Countdown info is stamped into data-reset-at (ISO
// string) or data-reset-secs (seconds) attributes on .oc-win-reset
// elements, so updateTimers() uniformly walks every tab container.
function renderOpenCodeKeyGroups(keys) {
const WIN_DEFS = [
{ name: "Rolling 5h", key: "rolling" },
{ name: "Weekly", key: "weekly" },
{ name: "Monthly", key: "monthly" }
]; ];
let worst = "online";
const rank = { online: 0, warning: 1, exceeded: 2 }; const rank = { online: 0, warning: 1, exceeded: 2 };
// provider 级别 = 全部 key 最差(失败 key 按 exceeded 视觉计入)
let worst = "online";
let html = "";
defs.forEach(function (d) { keys.forEach(function (k) {
const w = d.w || {}; const keyId = esc(k.key_id || "***");
const pct = clampPercent(w.percent); if (k.ok && k.windows) {
const level = levelOf(pct, w.exceeded, w.status); const windows = k.windows;
if (rank[level] > rank[worst]) worst = level; let keyWorst = "online";
let rowsHtml = "";
d.badge.textContent = pct === null ? "- %" : pct.toFixed(1) + " %"; WIN_DEFS.forEach(function (def) {
d.bar.style.width = (pct === null ? 0 : pct) + "%"; const w = windows[def.key] || {};
d.bar.className = "progress-bar" + (level === "exceeded" ? " danger" : level === "warning" ? " warning" : ""); const pct = clampPercent(w.percent);
d.card.className = "quota-card" + (level === "exceeded" ? " is-exceeded" : ""); const level = levelOf(pct, w.exceeded, w.status);
d.chip.className = "status-badge " + level; if (rank[level] > rank[keyWorst]) keyWorst = level;
d.chipText.textContent = level === "exceeded" ? "超限" : level === "warning" ? "紧张" : "正常"; if (rank[level] > rank[worst]) worst = level;
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;都没有则显示 "-" // reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显 "-"
let target = null; let resetAt = "";
if (w.reset_at) { let resetSecs = "";
const t = new Date(w.reset_at); if (w.reset_at) {
if (!isNaN(t.getTime())) target = t; const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) resetAt = t.toISOString();
}
if (!resetAt && w.reset_in_seconds > 0) {
resetSecs = String(w.reset_in_seconds);
}
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
rowsHtml += '<div class="oc-win-row">' +
'<span class="oc-win-name">' + def.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>';
});
const chipText = keyWorst === "exceeded" ? "超限" : keyWorst === "warning" ? "紧张" : "正常";
html += '<div class="oc-key-group">' +
'<div class="oc-key-head"><span class="oc-key-id">' + keyId + '</span>' +
'<span class="oc-key-chip status-badge ' + keyWorst + '"><span class="status-dot"></span><span>' + chipText + '</span></span></div>' +
'<div class="oc-key-body">' + rowsHtml + '</div>' +
'</div>';
} else {
// 失败 key 无 windows 字段:只渲染 key 头 + 错误行
if (rank.exceeded > rank[worst]) worst = "exceeded";
const is401 = k.status_code === 401;
const label = is401 ? "凭据无效" : "查询错误";
const statusPart = k.status_code ? "上游 " + esc(String(k.status_code)) : "查询失败";
const errTail = k.error ? ":" + esc(k.error) : "";
const tip = is401 ? ",请检查该 key 或从配置中移除" : "";
html += '<div class="oc-key-group">' +
'<div class="oc-key-head"><span class="oc-key-id">' + keyId + '</span>' +
'<span class="oc-key-chip status-badge exceeded"><span class="status-dot"></span><span>' + label + '</span></span></div>' +
'<div class="oc-key-error">' + label + '(' + statusPart + ')' + errTail + tip + '</div>' +
'</div>';
} }
if (!target && w.reset_in_seconds > 0) {
target = new Date(Date.now() + Number(w.reset_in_seconds) * 1000);
}
ocTargets[d.key] = target;
if (!target) d.timer.textContent = "-";
}); });
setProviderStatus("opencode", worst); return { html: html, worst: worst };
} }
function renderAllTab() { function renderAllTab() {
@@ -1581,12 +1627,43 @@ const QuotaPageHTML = `<!DOCTYPE html>
}); });
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level); allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-"; allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
// Plan + 三个限额窗口(百分比/进度条/重置时间),行结构与 OpenCode 卡片一致,
// 倒计时走 data-reset-at/data-reset-secs 属性,由 updateTimers() 统一刷新
const ccWins = [
{ name: "Rolling 5h", o: limits.five_hour || {} },
{ name: "Weekly", o: limits.weekly || {} },
{ name: "Monthly", o: limits.monthly || {} }
];
let ccRows = "";
ccWins.forEach(function (d) {
const w = d.o;
const pct = clampPercent(w.percentage);
const level = levelOf(pct, w.exceeded, "");
let resetAt = "";
let resetSecs = "";
if (w.reset_at) {
const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) resetAt = t.toISOString();
}
if (!resetAt && w.reset_in_seconds > 0) {
resetSecs = String(w.reset_in_seconds);
}
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
ccRows += '<div class="oc-win-row">' +
'<span class="oc-win-name">' + d.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>';
});
allBodyCommandcode.innerHTML = allBodyCommandcode.innerHTML =
summaryRow("Plan", esc(planName)) + summaryRow("Plan", esc(planName)) +
summaryRow("Total Credits", formatUSD(credits.total_credits)) + '<div class="oc-key-body">' + ccRows + '</div>';
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
miniBar(worstPct, worstLevel);
} else { } else {
allBadgeCommandcode.className = "status-badge"; allBadgeCommandcode.className = "status-badge";
allBadgeTextCommandcode.textContent = "尚未加载"; allBadgeTextCommandcode.textContent = "尚未加载";
@@ -1600,19 +1677,10 @@ const QuotaPageHTML = `<!DOCTYPE html>
allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>'; allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>';
} else if (oc.data) { } else if (oc.data) {
const data = oc.data; const data = oc.data;
const windows = data.windows || {}; // 多 key 契约:逐 key 完整卡片,与 OpenCode tab 共用同一渲染函数
const defs = [ const keys = Array.isArray(data.keys) ? data.keys : [];
{ name: "Rolling 5h", w: windows.rolling }, const rendered = renderOpenCodeKeyGroups(keys);
{ name: "Weekly", w: windows.weekly }, const html = rendered.html || '<div class="all-summary-value">无 key 数据</div>';
{ name: "Monthly", w: windows.monthly }
];
let html = "";
defs.forEach(function (d) {
const win = d.w || {};
const pct = clampPercent(win.percent);
const level = levelOf(pct, win.exceeded, win.status);
html += summaryRow(d.name, pct === null ? "-" : pct.toFixed(1) + "%") + miniBar(pct, level);
});
allBadgeOpencode.className = "status-badge " + badgeVisualClass(oc.level); allBadgeOpencode.className = "status-badge " + badgeVisualClass(oc.level);
allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-"; allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-";
allBodyOpencode.innerHTML = html; allBodyOpencode.innerHTML = html;
@@ -1632,9 +1700,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode"); document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode");
document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode"); document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode");
document.getElementById("sectionAll").classList.toggle("active", tab === "all"); document.getElementById("sectionAll").classList.toggle("active", tab === "all");
updateGlobalBadge();
if (updateHash) { if (updateHash) {
if (tab === "commandcode") { if (tab === "all") {
// All 为默认 tab:激活 All 时清掉 hash(刷新回到默认)
history.replaceState(null, "", location.pathname + location.search); history.replaceState(null, "", location.pathname + location.search);
} else { } else {
location.hash = tab; location.hash = tab;
@@ -1648,7 +1716,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
const mgmtKey = getStoredManagementKey(); const mgmtKey = getStoredManagementKey();
const overrideToken = inputSessionToken.value.trim(); const overrideToken = inputSessionToken.value.trim();
const overrideOpenCodeKey = inputOpenCodeKey.value.trim(); const overrideOpenCodeKeys = inputOpenCodeKeys.value.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
const headers = { const headers = {
"Accept": "application/json", "Accept": "application/json",
@@ -1665,8 +1733,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (overrideToken) { if (overrideToken) {
bodyObj.session_token = overrideToken; bodyObj.session_token = overrideToken;
} }
if (overrideOpenCodeKey) { // 非空才进 body:list 优先(后端仍接受旧 scalar 字段,前端不再发送)
bodyObj.opencode_api_key = overrideOpenCodeKey; if (overrideOpenCodeKeys.length > 0) {
bodyObj.opencode_api_keys = overrideOpenCodeKeys;
} }
try { try {
@@ -1679,8 +1748,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
settingsDrawer.classList.add("open"); settingsDrawer.classList.add("open");
showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true); showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true);
statusBadge.className = "status-badge warning";
statusText.textContent = "未授权";
return; return;
} }
@@ -1833,11 +1900,12 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
// Restore tab from location.hash, then initial fetch // Restore tab from location.hash, then initial fetch
// 无 hash 默认 All:All tab 才能通过刷新后的 #all hash 恢复
const initHash = location.hash.replace(/^#/, ""); const initHash = location.hash.replace(/^#/, "");
if (initHash === "opencode" || initHash === "all") { if (initHash === "opencode" || initHash === "commandcode") {
setActiveTab(initHash, false); setActiveTab(initHash, false);
} else { } else {
setActiveTab("commandcode", false); setActiveTab("all", false);
} }
// Initial fetch // Initial fetch
+27 -2
View File
@@ -294,10 +294,35 @@ type OpenCodeFormattedUsageResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// OpenCodeKeyResult is the per-key outcome of a multi-key OpenCode Go query
// (v0.4.0). Windows is a pointer so failed keys omit the field entirely
// instead of marshaling a zero-value struct with "status":"" noise.
type OpenCodeKeyResult struct {
KeyID string `json:"key_id"`
OK bool `json:"ok"`
Windows *OpenCodeFormattedWindows `json:"windows,omitempty"`
StatusCode int `json:"status_code"`
Error string `json:"error,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// OpenCodeMultiKeyResponse is the multi-key OpenCode Go usage payload returned
// by /plugins/commandcode/opencode/usage and the opencode field of /all.
// Top-level Error is non-empty only when no key is configured at all.
type OpenCodeMultiKeyResponse struct {
OK bool `json:"ok"`
Provider string `json:"provider"` // "opencode_go"
Keys []OpenCodeKeyResult `json:"keys"`
UpdatedAt string `json:"updated_at"`
Error string `json:"error,omitempty"`
}
// AllUsageResponse aggregates both providers for /plugins/commandcode/all. // AllUsageResponse aggregates both providers for /plugins/commandcode/all.
// Partial failure semantics: each provider's payload is present only on success; // Partial failure semantics: each provider's payload is present only on success;
// failures are reported in Errors. CommandCode/OpenCode carry the raw JSON of the // failures are reported in Errors. CommandCode carries the raw JSON of
// respective FormattedUsageResponse / OpenCodeFormattedUsageResponse. // FormattedUsageResponse; OpenCode carries the raw JSON of
// OpenCodeMultiKeyResponse (v0.4.0 breaking change: no longer the single-key
// OpenCodeFormattedUsageResponse).
type AllUsageResponse struct { type AllUsageResponse struct {
OK bool `json:"ok"` // at least one provider succeeded OK bool `json:"ok"` // at least one provider succeeded
CommandCode json.RawMessage `json:"commandcode,omitempty"` CommandCode json.RawMessage `json:"commandcode,omitempty"`
+25
View File
@@ -224,6 +224,31 @@ func clampOpenCodePercent(p float64) float64 {
return math.Round(p*100) / 100 return math.Round(p*100) / 100
} }
// MaskAPIKey masks an OpenCode Go API key for display: first 4 + "…" + last 4
// characters (e.g. "sk-L…KqYB"). Keys shorter than 8 characters are fully
// masked as "***"; an empty key masks to "".
func MaskAPIKey(key string) string {
if key == "" {
return ""
}
if len(key) < 8 {
return "***"
}
return key[:4] + "…" + key[len(key)-4:]
}
// QueryOpenCodeKeys queries OpenCode Go usage for each key sequentially and
// returns one typed result per key, in input order. A single key's failure is
// recorded only in that key's result and never aborts the loop.
func QueryOpenCodeKeys(ctx context.Context, apiBase string, keys []string, hostCallbackID string) []OpenCodeKeyResult {
results := make([]OpenCodeKeyResult, 0, len(keys))
for _, key := range keys {
res, _ := queryOpenCodeKey(ctx, apiBase, key, hostCallbackID)
results = append(results, res)
}
return results
}
// ParseAndFormatUsage parses upstream credits JSON into structured usage metrics. // ParseAndFormatUsage parses upstream credits JSON into structured usage metrics.
// summary (optional) carries the billing-period usage totals used to derive the monthly window. // summary (optional) carries the billing-period usage totals used to derive the monthly window.
func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now time.Time) (*FormattedUsageResponse, error) { func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now time.Time) (*FormattedUsageResponse, error) {
+104
View File
@@ -642,6 +642,110 @@ func TestFetchOpenCodeUsageRaw_UpstreamNon200(t *testing.T) {
} }
} }
func TestMaskAPIKey(t *testing.T) {
tests := []struct {
name string
key string
want string
}{
{"empty", "", ""},
{"normal key", "sk-LongExampleKqYB", "sk-L…KqYB"},
{"exactly 8 chars", "12345678", "1234…5678"},
{"7 chars fully masked", "1234567", "***"},
{"1 char", "x", "***"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := MaskAPIKey(tt.key); got != tt.want {
t.Errorf("MaskAPIKey(%q) = %q, want %q", tt.key, got, tt.want)
}
})
}
}
// Double-key isolation: one key succeeds, the other gets a 401 — the failure
// must be contained in its own result, must not abort the loop, and the raw
// key must never appear in any result field.
func TestQueryOpenCodeKeys_IsolationAndOrder(t *testing.T) {
var authOrder []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authOrder = append(authOrder, r.Header.Get("Authorization"))
switch r.Header.Get("Authorization") {
case "Bearer sk-good-AAAA":
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})
}()
keys := []string{"sk-good-AAAA", "sk-bad-BBBB"}
results := QueryOpenCodeKeys(context.Background(), ts.URL, keys, "")
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
// Order preserved: requests issued in input order.
if len(authOrder) != 2 || authOrder[0] != "Bearer sk-good-AAAA" || authOrder[1] != "Bearer sk-bad-BBBB" {
t.Errorf("request order = %v, want sequential input order", authOrder)
}
ok := results[0]
if !ok.OK || ok.StatusCode != http.StatusOK {
t.Errorf("results[0] = %+v, want OK=true status=200", ok)
}
if ok.Windows == nil {
t.Fatal("results[0].Windows = nil, want non-nil on success")
}
if ok.Windows.Rolling.Percent != 4 || ok.Windows.Weekly.Percent != 46 || ok.Windows.Monthly.Percent != 23 {
t.Errorf("results[0] percents = %v/%v/%v, want 4/46/23",
ok.Windows.Rolling.Percent, ok.Windows.Weekly.Percent, ok.Windows.Monthly.Percent)
}
if ok.KeyID != MaskAPIKey("sk-good-AAAA") {
t.Errorf("results[0].KeyID = %q, want masked id %q", ok.KeyID, MaskAPIKey("sk-good-AAAA"))
}
bad := results[1]
if bad.OK {
t.Errorf("results[1].OK = true, want false (401 must not abort the loop)")
}
if bad.Windows != nil {
t.Errorf("results[1].Windows = %+v, want nil on failure", bad.Windows)
}
if bad.StatusCode != http.StatusUnauthorized {
t.Errorf("results[1].StatusCode = %d, want 401", bad.StatusCode)
}
if !strings.Contains(bad.Error, "opencode upstream returned 401") {
t.Errorf("results[1].Error = %q, want it to mention the upstream 401", bad.Error)
}
// Raw keys must never leak into any serialized result field.
raw, _ := json.Marshal(results)
if strings.Contains(string(raw), "sk-good-AAAA") || strings.Contains(string(raw), "sk-bad-BBBB") {
t.Errorf("serialized results leak a raw key: %s", string(raw))
}
}
func TestQueryOpenCodeKeys_EmptyKeyInList(t *testing.T) {
SetHostCaller(nil)
results := QueryOpenCodeKeys(context.Background(), "", []string{""}, "")
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if results[0].OK || results[0].StatusCode != http.StatusBadRequest {
t.Errorf("results[0] = %+v, want local 400 result", results[0])
}
}
func TestFetchOpenCodeUsageRaw_HostCaller(t *testing.T) { func TestFetchOpenCodeUsageRaw_HostCaller(t *testing.T) {
mockResponsePayload := []byte(`{"usage":{"rolling":{"status":"ok","percent":7,"resetsAt":"2026-09-17T06:58:53Z"}}}`) mockResponsePayload := []byte(`{"usage":{"rolling":{"status":"ok","percent":7,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
+217
View File
@@ -0,0 +1,217 @@
// pagecheck: syntax-check the embedded quota page JS (guards against
// parse-time SyntaxErrors like duplicate const that break the whole page).
const fs = require("fs");
const src = fs.readFileSync("plugin/quota_page.go", "utf8");
const m = src.match(/const QuotaPageHTML = `([\s\S]*)`/);
if (!m) {
console.error("pagecheck: QuotaPageHTML not found");
process.exit(1);
}
const js = [...m[1].matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map((x) => x[1])
.join("\n");
if (!js.trim()) {
console.error("pagecheck: no <script> content found");
process.exit(1);
}
try {
new Function(js);
} catch (e) {
console.error("pagecheck: embedded JS SyntaxError:", e.message);
process.exit(1);
}
console.log("pagecheck: embedded JS syntax OK");
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
// updateTimers() that was never declared and only existed as a global
// property accidentally created by renderUsage()).
//
// Approach (deliberately simple/grep-style, no DOM execution):
// 1. strip comments and string literals
// 2. collect every var/let/const/function declaration name + function/
// callback/catch parameter
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
// bare if()/while() condition identifiers, and for-loop init identifiers
// 4. whitelist known globals; anything left is reported and fails the check
const whitelist = new Set([
// browser builtins referenced by the page
"document", "window", "localStorage", "sessionStorage", "fetch",
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
"escape", "unescape", "navigator", "location", "history", "URL",
"URLSearchParams", "FormData", "Headers", "Request", "Response",
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
]);
function stripLiterals(source) {
// Remove comments, string literals, and regex literals; replace with
// harmless placeholders so identifier scanning never sees string content.
// Template literals are NOT fully discarded: their ${...} interpolations
// are kept as "( ... )" so identifiers inside them stay visible.
//
// A "/" only starts a regex literal when it appears in expression
// position (previous significant token is an operator/open bracket or a
// keyword such as return/typeof). Otherwise it is division.
let out = "";
let i = 0;
const n = source.length;
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
const stack = [{ type: "expr", depth: Infinity }];
// Last significant (non-whitespace) emitted char + last identifier word,
// used for the regex-vs-division heuristic.
let lastSig = "";
let lastWord = "";
const KEYWORDS_BEFORE_REGEX = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
"do", "else", "case", "throw", "await", "yield",
]);
const emit = (text) => {
for (const ch of text) {
if (/\s/.test(ch)) continue;
if (/[A-Za-z0-9_$]/.test(ch)) {
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
} else {
lastWord = "";
}
lastSig = ch;
}
out += text;
};
const regexAllowed = () =>
lastSig === "" ||
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
KEYWORDS_BEFORE_REGEX.has(lastWord);
while (i < n) {
const ctx = stack[stack.length - 1];
const c = source[i];
const next = source[i + 1];
if (ctx.type === "tmpl") {
// Inside template literal text: skip until ` or ${ ... }
if (c === "\\") { i += 2; continue; }
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
if (c === "$" && next === "{") {
i += 2;
stack.push({ type: "expr", depth: 0 });
emit(" ( ");
continue;
}
i++;
continue;
}
// code context (top-level or template ${...} expression)
if (c === "/" && next === "/") {
while (i < n && source[i] !== "\n") i++;
} else if (c === "/" && next === "*") {
i += 2;
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
i += 2;
} else if (c === "/" && regexAllowed()) {
// regex literal: skip to unescaped closing / (not inside [...])
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") { inClass = true; i++; continue; }
if (source[i] === "]") { inClass = false; i++; continue; }
if (source[i] === "/" && !inClass) { i++; break; }
if (source[i] === "\n") break; // malformed; bail out safely
i++;
}
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
emit(" / ");
} else if (c === '"' || c === "'") {
const quote = c;
i++;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === quote) { i++; break; }
if (source[i] === "\n") break;
i++;
}
emit(" " + quote + quote + " ");
} else if (c === "`") {
i++;
stack.push({ type: "tmpl" });
emit(" ");
} else {
if (c === "{") ctx.depth++;
if (c === "}") {
if (ctx.depth === 0) {
// closes a template interpolation: back into template text
stack.pop();
i++;
emit(" ) ");
continue;
}
ctx.depth--;
}
emit(c);
i++;
}
}
return out;
}
function collectDeclarations(clean) {
const declared = new Set();
const addParamList = (raw) => {
for (const p of raw.split(",")) {
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
}
};
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
declared.add(m[1]);
// function declarations/expressions: name + params
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
if (m[1]) declared.add(m[1]);
addParamList(m[2]);
}
// arrow functions: (a, b) => and a =>
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
// catch (e) and destructuring catch
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
return declared;
}
const clean = stripLiterals(js);
const declared = collectDeclarations(clean);
const flagged = new Set();
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 2. bare if()/while() condition identifiers
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
const name = m[2];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 3. for-loop init without let/var: for (i = 0; ...)
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
if (flagged.size > 0) {
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
for (const name of [...flagged].sort()) console.error(" - " + name);
console.error(
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
"if this is a false positive, extend scripts/pagecheck.js"
);
process.exit(1);
}
console.log(
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
);