7 Commits
Author SHA1 Message Date
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 1164 additions and 375 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.
+346 -9
View File
@@ -74,6 +74,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.4") {
t.Errorf("Body does not contain version badge v0.4.4")
}
} }
} }
@@ -230,19 +233,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 +266,175 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
} }
} }
// /opencode/usage status matrix (v0.4.0): >=1 key success → 200; one success
// + one 401 → 200 with keys[1].ok=false and no windows; all 401 → 502; no
// keys configured → 400 with the "no opencode api keys configured" prefix.
func TestHandleManagement_OpencodeUsage_MultiKeyMatrix(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("Authorization") {
case "Bearer sk-good-AAAA", "Bearer sk-good-ZZZZ":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
default:
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
postKeys := func(keys ...string) ManagementRequest {
body, _ := json.Marshal(map[string]any{
"opencode_api_keys": keys,
"opencode_api_base": ts.URL,
})
return ManagementRequest{
Method: http.MethodPost,
Path: "/plugins/commandcode/opencode/usage",
Body: body,
}
}
t.Run("both keys succeed → 200", func(t *testing.T) {
resp, err := HandleManagement(context.Background(), postKeys("sk-good-AAAA", "sk-good-ZZZZ"), nil)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
var usage OpenCodeMultiKeyResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !usage.OK || len(usage.Keys) != 2 || !usage.Keys[0].OK || !usage.Keys[1].OK {
t.Errorf("unexpected response: %+v", usage)
}
if usage.Error != "" {
t.Errorf("top-level error = %q, want empty when keys are configured", usage.Error)
}
})
t.Run("one success one 401 → 200 with failed key isolated", func(t *testing.T) {
resp, err := HandleManagement(context.Background(), postKeys("sk-good-AAAA", "sk-bad-BBBB"), nil)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200 (>=1 key succeeded), body=%s", resp.StatusCode, string(resp.Body))
}
var usage OpenCodeMultiKeyResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !usage.OK || len(usage.Keys) != 2 {
t.Fatalf("unexpected response: %+v", usage)
}
if !usage.Keys[0].OK || usage.Keys[0].Windows == nil {
t.Errorf("keys[0] = %+v, want ok with windows", usage.Keys[0])
}
if usage.Keys[1].OK || usage.Keys[1].Windows != nil {
t.Errorf("keys[1] = %+v, want not-ok with nil windows", usage.Keys[1])
}
if usage.Keys[1].StatusCode != http.StatusUnauthorized {
t.Errorf("keys[1].status_code = %d, want 401", usage.Keys[1].StatusCode)
}
// windows must be omitted from the JSON for the failed key, not
// serialized as null or a zero-value struct.
var raw struct {
Keys []struct {
Windows json.RawMessage `json:"windows"`
} `json:"keys"`
}
if err := json.Unmarshal(resp.Body, &raw); err != nil {
t.Fatalf("unmarshal raw error: %v", err)
}
if len(raw.Keys[1].Windows) != 0 {
t.Errorf("keys[1].windows in JSON = %s, want omitted", string(raw.Keys[1].Windows))
}
if !strings.Contains(usage.Keys[1].Error, "opencode upstream returned 401") {
t.Errorf("keys[1].error = %q, want upstream 401 mention", usage.Keys[1].Error)
}
if strings.Contains(string(resp.Body), "sk-bad-BBBB") {
t.Errorf("response leaks the raw key: %s", string(resp.Body))
}
})
t.Run("all keys 401 → 502", func(t *testing.T) {
resp, err := HandleManagement(context.Background(), postKeys("sk-bad-CCCC", "sk-bad-DDDD"), nil)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("StatusCode = %d, want 502 (all keys upstream-failed), body=%s", resp.StatusCode, string(resp.Body))
}
var usage OpenCodeMultiKeyResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if usage.OK {
t.Errorf("OK = true, want false when all keys fail")
}
if usage.Error != "" {
t.Errorf("top-level error = %q, want empty (per-key errors carry the detail)", usage.Error)
}
})
t.Run("no keys configured → 400", func(t *testing.T) {
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/opencode/usage",
}
resp, err := HandleManagement(context.Background(), req, &PluginConfig{})
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("StatusCode = %d, want 400, body=%s", resp.StatusCode, string(resp.Body))
}
var usage OpenCodeMultiKeyResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !strings.HasPrefix(usage.Error, "no opencode api keys configured") {
t.Errorf("top-level error = %q, want prefix 'no opencode api keys configured'", usage.Error)
}
})
t.Run("POST body opencode_api_keys overrides config and wins over scalar", func(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"opencode_api_key": "sk-scalar-must-lose",
"opencode_api_keys": []string{"sk-good-AAAA"},
"opencode_api_base": ts.URL,
})
req := ManagementRequest{
Method: http.MethodPost,
Path: "/plugins/commandcode/opencode/usage",
Body: body,
}
cfg := &PluginConfig{OpenCodeAPIKey: "sk-config-must-lose", OpenCodeAPIBase: ts.URL}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
var usage OpenCodeMultiKeyResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if len(usage.Keys) != 1 || usage.Keys[0].KeyID != MaskAPIKey("sk-good-AAAA") {
t.Errorf("keys = %+v, want only the body-list key (list wins over scalar and config)", usage.Keys)
}
})
}
// Regression: /plugins/commandcode/all must not be swallowed by the generic // 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 +497,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 +564,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 +639,154 @@ func TestHandleManagement_AllUsage_Upstream400NotMisclassified(t *testing.T) {
} }
} }
// /all matrix (v0.4.0): Command Code upstream down + all opencode keys 401
// → every failure is upstream → 502, with the aggregate "all N keys failed"
// message in errors["opencode"].
func TestHandleManagement_AllUsage_CCUpstreamDown_OpenCodeAll401(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/usage":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
default: // commandcode internal endpoints
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"cc exploded"}`))
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{
SessionToken: "configured-token",
APIBase: ts.URL,
OpenCodeAPIKeys: []string{"sk-bad-AAAA", "sk-bad-BBBB"},
OpenCodeAPIBase: ts.URL,
}
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/all",
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("StatusCode = %d, want 502 (all failures upstream), body=%s", resp.StatusCode, string(resp.Body))
}
var all AllUsageResponse
if err := json.Unmarshal(resp.Body, &all); err != nil {
t.Fatalf("unmarshal body error: %v", err)
}
if all.OK {
t.Error("expected ok=false")
}
if !strings.Contains(all.Errors["opencode"], "all 2 opencode keys failed") {
t.Errorf("errors[opencode] = %q, want 'all 2 opencode keys failed'", all.Errors["opencode"])
}
}
// /all matrix: Command Code upstream down + one opencode key succeeds → 200
// (partial failure); the multi-key opencode payload is inlined.
func TestHandleManagement_AllUsage_CCUpstreamDown_OpenCodeOneOK(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/usage" && r.Header.Get("Authorization") == "Bearer sk-good-AAAA":
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
case r.URL.Path == "/usage":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
default: // commandcode internal endpoints
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"cc exploded"}`))
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{
SessionToken: "configured-token",
APIBase: ts.URL,
OpenCodeAPIKeys: []string{"sk-good-AAAA", "sk-bad-BBBB"},
OpenCodeAPIBase: ts.URL,
}
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/all",
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200 (opencode partial success), body=%s", resp.StatusCode, string(resp.Body))
}
var all AllUsageResponse
if err := json.Unmarshal(resp.Body, &all); err != nil {
t.Fatalf("unmarshal body error: %v", err)
}
if !all.OK {
t.Error("expected ok=true (>=1 provider succeeded)")
}
if _, present := all.Errors["commandcode"]; !present {
t.Errorf("expected errors[commandcode], got %v", all.Errors)
}
if _, present := all.Errors["opencode"]; present {
t.Errorf("errors[opencode] must be absent on partial success, got %q", all.Errors["opencode"])
}
var oc OpenCodeMultiKeyResponse
if err := json.Unmarshal(all.OpenCode, &oc); err != nil || !oc.OK {
t.Fatalf("opencode payload invalid: err=%v oc=%+v", err, oc)
}
if len(oc.Keys) != 2 || !oc.Keys[0].OK || oc.Keys[1].OK {
t.Errorf("opencode keys = %+v, want [ok, failed]", oc.Keys)
}
if strings.Contains(string(resp.Body), "sk-good-AAAA") || strings.Contains(string(resp.Body), "sk-bad-BBBB") {
t.Errorf("/all response leaks a raw opencode key: %s", string(resp.Body))
}
}
func TestIsLocalCredentialError(t *testing.T) {
local := []string{
"session_token is required. Configure ...",
"opencode_api_key is required. Configure ...",
// v0.4.0 prefix: zero opencode keys configured is a local problem.
"no opencode api keys configured. Configure opencode_api_keys (YAML list) ...",
}
for _, msg := range local {
if !isLocalCredentialError(msg) {
t.Errorf("isLocalCredentialError(%q) = false, want true", msg)
}
}
upstream := []string{
"opencode upstream returned 401: check opencode_api_key",
"opencode upstream request failed: dial tcp: connection refused",
"all 2 opencode keys failed",
"upstream returned non-200 status",
"failed to parse opencode upstream usage: unexpected end of JSON input",
"",
}
for _, msg := range upstream {
if isLocalCredentialError(msg) {
t.Errorf("isLocalCredentialError(%q) = true, want false", msg)
}
}
}
// Unknown path after the new routes still 404s. // 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.4"
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])
}
})
} }
+294 -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.4</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,31 +1233,6 @@ 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 weeklyTargetTime = null; let weeklyTargetTime = null;
let timerInterval = null; let timerInterval = null;
@@ -1249,8 +1242,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 +1338,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 +1366,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 +1381,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 +1475,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 +1488,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 +1625,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 +1675,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 +1698,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 +1714,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 +1731,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 +1746,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 +1898,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"}}}`)
+23
View File
@@ -0,0 +1,23 @@
// 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");