2 Commits
Author SHA1 Message Date
yuez 61c50280b1 feat: add OpenCode Go usage query with aggregated /all endpoint, bump v0.3.0
- New provider: OpenCode Go (GET https://opencode.ai/zen/go/v1/usage, Bearer auth)
- Extract shared transport doUpstreamRequest (host.http.do first, net/http fallback)
- New management routes: GET/POST /plugins/commandcode/opencode/usage, GET/POST /plugins/commandcode/all
- /all aggregates both providers sequentially with partial-failure semantics
  (>=1 success -> 200, all-local-missing -> 400, all-upstream-failure -> 502)
- QuotaCard UI: tabs (Command Code / OpenCode Go / All), OpenCode window cards,
  version badge v0.3.0, OpenCode API key test override in settings drawer
- Config: opencode_api_key / opencode_api_base (ConfigFields 2 -> 4)
- Tests: route-order regression, /all partial failure & misclassification guards,
  ParseOpenCodeUsage edge cases, host/http transport paths
2026-09-18 09:38:40 +08:00
zgs225 43c09885e5 chore: stop tracking release artifacts (uploaded to GitHub Releases) 2026-09-10 11:10:47 +08:00
12 changed files with 1908 additions and 75 deletions
+8
View File
@@ -15,3 +15,11 @@ coverage.txt
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp
# Release artifacts (attached to GitHub Releases, not tracked in git)
*.zip
checksums.txt
dist/
# Internal planning docs (not for public repo)
docs/
+68 -5
View File
@@ -4,7 +4,7 @@
[![CLIProxyAPI Plugin ABI](https://img.shields.io/badge/C%20ABI-v1-emerald.svg)](https://help.router-for.me/plugin/development.html) [![CLIProxyAPI Plugin ABI](https://img.shields.io/badge/C%20ABI-v1-emerald.svg)](https://help.router-for.me/plugin/development.html)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 与 **OpenCode Go** 两个上游的配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard,Tab: Command Code / OpenCode Go / All)。
--- ---
@@ -20,6 +20,8 @@
- [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard) - [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard)
- [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get) - [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get)
- [3. 管理 API: 测试用量 (`POST`)](#3-管理-api-测试用量-post) - [3. 管理 API: 测试用量 (`POST`)](#3-管理-api-测试用量-post)
- [4. 管理 API: OpenCode Go 用量 (`opencode/usage`)](#4-管理-api-opencode-go-用量-opencodeusage)
- [5. 管理 API: 聚合查询 (`all`)](#5-管理-api-聚合查询-all)
- [用量数据结构说明](#用量数据结构说明) - [用量数据结构说明](#用量数据结构说明)
- [开发与测试](#开发与测试) - [开发与测试](#开发与测试)
- [许可证](#许可证) - [许可证](#许可证)
@@ -45,6 +47,11 @@
- 页面挂载于 `/v0/resource/plugins/commandcode/quota`。 - 页面挂载于 `/v0/resource/plugins/commandcode/quota`。
- 零外部 CDN 依赖,纯内置 HTML + CSS + JS,深色/浅色模式自适应。 - 零外部 CDN 依赖,纯内置 HTML + CSS + JS,深色/浅色模式自适应。
- 具有进度条颜色变化、5小时/周限额卡片、秒级动态重置倒计时、同源 `localStorage` 鉴权与一键刷新。 - 具有进度条颜色变化、5小时/周限额卡片、秒级动态重置倒计时、同源 `localStorage` 鉴权与一键刷新。
- Tab 切换:Command Code / OpenCode Go / All(`#opencode` / `#all` hash 记忆状态)。
6. **OpenCode Go 用量查询 (v0.3.0+)**:
- 上游接口:`GET https://opencode.ai/zen/go/v1/usage`,`Authorization: Bearer` 认证(同样走 `host.http.do` 优先 + `net/http` 兜底)。
- 解析 rolling(5h)/ weekly / monthly 三个窗口的 `status`/`percent`/`resetsAt`,容忍未知 status 值。
- 聚合端点 `/plugins/commandcode/all` 一次返回两个 provider,部分失败不拖死另一 provider。
--- ---
@@ -76,7 +83,8 @@
└──────────────────────────────┼─────────────────────────┘ └──────────────────────────────┼─────────────────────────┘
│ Upstream HTTPS │ Upstream HTTPS
▼ ▼
https://api.commandcode.ai/internal/billing/credits https://api.commandcode.ai/internal/billing/credits
https://opencode.ai/zen/go/v1/usage (v0.3.0+)
``` ```
--- ---
@@ -126,6 +134,8 @@ plugins:
priority: 1 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_base: "https://opencode.ai/zen/go/v1" # v0.3.0+ 可选,默认为官方接口
``` ```
--- ---
@@ -135,11 +145,11 @@ plugins:
### 1. 浏览器资源页 (`QuotaCard`) ### 1. 浏览器资源页 (`QuotaCard`)
- **访问路径**:`GET http://<cpa-host>:8317/v0/resource/plugins/commandcode/quota` - **访问路径**:`GET http://<cpa-host>:8317/v0/resource/plugins/commandcode/quota`
- **菜单名**:`Command Code 配额` - **菜单名**:`用量配额`
- **说明**: - **说明**:
- 资源请求本身无需经过管理认证,可在浏览器中直接打开或嵌入仪表盘。 - 资源请求本身无需经过管理认证,可在浏览器中直接打开或嵌入仪表盘。
- 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/usage` 请求数据。 - 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/all` 请求数据(一次获取 Command Code + OpenCode Go)。
- 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key 或测试 Session Token。 - 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key、测试 Session Token 或 OpenCode API Key(仅当次请求生效,不持久化)。
### 2. 管理 API: 查询用量 (`GET`) ### 2. 管理 API: 查询用量 (`GET`)
@@ -198,6 +208,55 @@ plugins:
} }
``` ```
### 4. 管理 API: OpenCode Go 用量 (`opencode/usage`)
- **端点**:`GET /v0/management/plugins/commandcode/opencode/usage`(认证同上,仅读插件配置;凭据覆盖走 POST)
- **端点**:`POST /v0/management/plugins/commandcode/opencode/usage`
- **POST 请求体**:
```json
{ "opencode_api_key": "sk-YOUR_TEMPORARY_KEY" }
```
- **响应示例**:
```json
{
"ok": true,
"provider": "opencode_go",
"windows": {
"rolling": { "status": "ok", "percent": 4, "exceeded": false,
"reset_at": "2026-09-17T06:58:53Z", "reset_in_seconds": 2520 },
"weekly": { "status": "ok", "percent": 46, "exceeded": false,
"reset_at": "2026-09-21T00:00:00Z", "reset_in_seconds": 259200 },
"monthly": { "status": "ok", "percent": 23, "exceeded": false,
"reset_at": "2026-10-14T09:13:49Z", "reset_in_seconds": 1728000 }
},
"updated_at": "2026-09-16T12:00:00Z"
}
```
### 5. 管理 API: 聚合查询 (`all`)
- **端点**:`GET /v0/management/plugins/commandcode/all`(仅读插件配置)
- **端点**:`POST /v0/management/plugins/commandcode/all`
- **POST 请求体**(可只带其一):
```json
{ "session_token": "...", "opencode_api_key": "sk-..." }
```
- **部分失败语义**:HTTP 200 表示至少一个 provider 成功;失败 provider 记入 `errors`,其响应字段(`commandcode`/`opencode`)整个省略;全失败且为本地凭据缺失 → 400,全失败且为上游错误 → 502。
```json
{
"ok": true,
"commandcode": { "ok": true, "plan": {...}, "credits": {...}, "window_limits": {...}, "updated_at": "..." },
"opencode": { "ok": true, "provider": "opencode_go", "windows": {...}, "updated_at": "..." },
"updated_at": "2026-09-16T12:00:00Z"
}
```
--- ---
## 用量数据结构说明 ## 用量数据结构说明
@@ -215,6 +274,10 @@ plugins:
| `window_limits.five_hour.reset_at` | `string` | 5小时窗口重置时间的 RFC3339 字符串 | | `window_limits.five_hour.reset_at` | `string` | 5小时窗口重置时间的 RFC3339 字符串 |
| `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.<...>.percent` | `float64` | OpenCode Go 窗口使用百分比(0-100,钳制) |
| `windows.<...>.exceeded` | `bool` | `percent >= 100` 或上游 `status == "exceeded"` |
| `windows.<...>.reset_at` / `reset_in_seconds` | `string` / `int64` | OpenCode Go 窗口重置时间(解析失败优雅降级为空/0) |
--- ---
-1
View File
@@ -1 +0,0 @@
7830fab0eb763247602fd4815549a13eeb25841afbe2d3a047d25b9f4b9cf86d commandcode_0.2.2_linux_amd64.zip
Binary file not shown.
+302 -3
View File
@@ -3,6 +3,7 @@ package plugin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -22,12 +23,32 @@ func RegisterManagement() (ManagementRegistrationResponse, error) {
Path: "/plugins/commandcode/usage", Path: "/plugins/commandcode/usage",
Description: "Query Command Code credits and window limits usage with custom session_token", Description: "Query Command Code credits and window limits usage with custom session_token",
}, },
{
Method: http.MethodGet,
Path: "/plugins/commandcode/opencode/usage",
Description: "Query OpenCode Go usage windows (rolling/weekly/monthly)",
},
{
Method: http.MethodPost,
Path: "/plugins/commandcode/opencode/usage",
Description: "Query OpenCode Go usage windows with custom opencode_api_key",
},
{
Method: http.MethodGet,
Path: "/plugins/commandcode/all",
Description: "Query both Command Code and OpenCode Go usage (aggregated, partial failures reported in errors map)",
},
{
Method: http.MethodPost,
Path: "/plugins/commandcode/all",
Description: "Query both providers with custom credentials in request body",
},
}, },
Resources: []ResourceRoute{ Resources: []ResourceRoute{
{ {
Path: "/quota", Path: "/quota",
Menu: "Command Code 配额", Menu: "用量配额",
Description: "Command Code 用量与限额卡片", Description: "Command Code + OpenCode Go 用量与限额卡片",
}, },
}, },
}, nil }, nil
@@ -49,7 +70,42 @@ func HandleManagement(ctx context.Context, req ManagementRequest, cfg *PluginCon
}, nil }, nil
} }
// 2. Serve Usage API (GET / POST) // 2. OpenCode Go usage API — MUST be matched before the generic /usage
// suffix match below, otherwise "/plugins/commandcode/opencode/usage"
// would be swallowed by the Command Code handler.
if strings.HasSuffix(path, "/plugins/commandcode/opencode/usage") {
switch method {
case http.MethodGet, http.MethodPost:
return handleOpenCodeUsage(ctx, req, cfg)
default:
return ManagementResponse{
StatusCode: http.StatusMethodNotAllowed,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
}, nil
}
}
// 3. Aggregated usage API (both providers) — does not end with "/usage",
// but registered before the generic match for clarity and future safety.
if strings.HasSuffix(path, "/plugins/commandcode/all") {
switch method {
case http.MethodGet, http.MethodPost:
return handleAllUsage(ctx, req, cfg)
default:
return ManagementResponse{
StatusCode: http.StatusMethodNotAllowed,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
}, nil
}
}
// 4. Command Code usage API (GET / POST) — generic suffix match kept as-is.
if strings.HasSuffix(path, "/plugins/commandcode/usage") || strings.HasSuffix(path, "/usage") { if strings.HasSuffix(path, "/plugins/commandcode/usage") || strings.HasSuffix(path, "/usage") {
switch method { switch method {
case http.MethodGet: case http.MethodGet:
@@ -215,3 +271,246 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
Body: resBytes, Body: resBytes,
}, nil }, nil
} }
// handleOpenCodeUsage serves GET/POST /plugins/commandcode/opencode/usage.
// Credentials can be overridden via POST body only (opencode_api_key / api_key);
// GET queries are read-only against the plugin config — query parameter
// overrides are intentionally not supported to keep secrets out of URLs.
func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
apiKey := ""
apiBase := ""
if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 {
var body struct {
OpenCodeAPIKey string `json:"opencode_api_key"`
APIKey string `json:"api_key"`
OpenCodeAPIBase string `json:"opencode_api_base"`
}
_ = json.Unmarshal(req.Body, &body)
apiKey = body.OpenCodeAPIKey
if apiKey == "" {
apiKey = body.APIKey
}
apiBase = body.OpenCodeAPIBase
}
// Fallback to plugin config
if apiKey == "" && cfg != nil {
apiKey = cfg.GetOpenCodeAPIKey()
}
if apiBase == "" && cfg != nil {
apiBase = cfg.GetOpenCodeAPIBase()
}
return handleOpenCodeUsageWithKey(ctx, apiBase, apiKey, req.HostCallbackID)
}
// handleAllUsage serves GET/POST /plugins/commandcode/all: it queries both
// providers sequentially (no goroutines — the host.http.do bridge's host-side
// concurrency safety cannot be verified and shared maps would race under -race).
// Partial failure: OK=true as long as at least one provider succeeds; failures
// land in the Errors map and successful fields are omitted when absent.
// HTTP status: any success → 200; all failed due to missing local credentials → 400;
// all failed due to upstream errors → 502.
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
sessionToken := ""
opencodeKey := ""
if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 {
var body struct {
SessionToken string `json:"session_token"`
OpencodeAPIKey string `json:"opencode_api_key"`
}
_ = json.Unmarshal(req.Body, &body)
sessionToken = body.SessionToken
opencodeKey = body.OpencodeAPIKey
}
// Fallback to plugin config
if sessionToken == "" && cfg != nil {
sessionToken = cfg.GetSessionToken()
}
if opencodeKey == "" && cfg != nil {
opencodeKey = cfg.GetOpenCodeAPIKey()
}
apiBase := ""
if cfg != nil {
apiBase = cfg.GetAPIBase()
}
ocAPIBase := ""
if cfg != nil {
ocAPIBase = cfg.GetOpenCodeAPIBase()
}
now := time.Now().UTC()
resp := AllUsageResponse{OK: false, UpdatedAt: now.Format(time.RFC3339)}
errs := make(map[string]string)
localMissing := 0
upstreamFailed := 0
succeeded := 0
// Provider 1: Command Code (reuses executeUsageQuery).
ccResp, _ := executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
if ccResp.StatusCode == http.StatusOK {
resp.CommandCode = ccResp.Body
succeeded++
} else {
ccErr := extractErrorResponseMessage(ccResp.Body)
errs["commandcode"] = ccErr
if isLocalCredentialError(ccErr) {
localMissing++
} else {
upstreamFailed++
}
}
// Provider 2: OpenCode Go (same classification via isLocalCredentialError,
// not by HTTP 400 alone: upstream 4xx may be passed through and must not
// be misclassified as a local configuration problem).
if strings.TrimSpace(opencodeKey) != "" {
ocResp, _ := handleOpenCodeUsageWithKey(ctx, ocAPIBase, opencodeKey, req.HostCallbackID)
if ocResp.StatusCode == http.StatusOK {
resp.OpenCode = ocResp.Body
succeeded++
} else {
ocErr := extractErrorResponseMessage(ocResp.Body)
errs["opencode"] = ocErr
if isLocalCredentialError(ocErr) {
localMissing++
} else {
upstreamFailed++
}
}
} else {
errs["opencode"] = "missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request body"
localMissing++
}
if len(errs) > 0 {
resp.Errors = errs
}
resp.OK = succeeded > 0
statusCode := http.StatusOK
if !resp.OK {
if upstreamFailed == 0 && localMissing == len(errs) {
statusCode = http.StatusBadRequest
} else {
statusCode = http.StatusBadGateway
}
}
resBytes, _ := json.Marshal(resp)
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
// isLocalCredentialError reports whether an /all provider error is a local
// configuration problem (missing credential in plugin config), as opposed to
// an upstream failure. Local-credential errors carry fixed message prefixes;
// upstream 4xx/5xx never match them, so the /all 400-vs-502 classification
// does not rely on the HTTP status alone.
func isLocalCredentialError(msg string) bool {
for _, prefix := range []string{
"session_token is required",
"opencode_api_key is required",
} {
if strings.HasPrefix(msg, prefix) {
return true
}
}
return false
}
// handleOpenCodeUsageWithKey runs the OpenCode usage query with an explicit
// credential, shared by handleOpenCodeUsage and handleAllUsage.
func handleOpenCodeUsageWithKey(ctx context.Context, apiBase, apiKey, hostCallbackID string) (ManagementResponse, error) {
if strings.TrimSpace(apiKey) == "" {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "opencode_api_key is required. Configure opencode_api_key in plugin config or pass it in the request body",
})
return ManagementResponse{
StatusCode: http.StatusBadRequest,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, apiKey, hostCallbackID)
if errFetch != nil {
errMsg := fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error())
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"status_code": statusCode,
"error": errMsg,
})
if statusCode == 0 || statusCode == http.StatusOK {
statusCode = http.StatusBadGateway
}
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
if statusCode != http.StatusOK {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"status_code": statusCode,
"error": fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode),
})
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
usage, errParse := ParseOpenCodeUsage(raw, time.Now().UTC())
if errParse != nil {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "failed to parse opencode upstream usage: " + errParse.Error(),
})
return ManagementResponse{
StatusCode: http.StatusBadGateway,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
resBytes, _ := json.Marshal(usage)
return ManagementResponse{
StatusCode: http.StatusOK,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
// extractErrorResponseMessage pulls the "error" field out of a JSON error body.
func extractErrorResponseMessage(body []byte) string {
var parsed struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" {
return parsed.Error
}
return "unknown error"
}
+319 -5
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestRegisterManagement(t *testing.T) { func TestRegisterManagement(t *testing.T) {
@@ -15,8 +16,8 @@ func TestRegisterManagement(t *testing.T) {
t.Fatalf("RegisterManagement error: %v", err) t.Fatalf("RegisterManagement error: %v", err)
} }
if len(resp.Routes) != 2 { if len(resp.Routes) != 6 {
t.Fatalf("len(Routes) = %d, want 2", len(resp.Routes)) t.Fatalf("len(Routes) = %d, want 6", len(resp.Routes))
} }
if resp.Routes[0].Method != http.MethodGet || resp.Routes[0].Path != "/plugins/commandcode/usage" { if resp.Routes[0].Method != http.MethodGet || resp.Routes[0].Path != "/plugins/commandcode/usage" {
t.Errorf("Route 0 mismatch: %+v", resp.Routes[0]) t.Errorf("Route 0 mismatch: %+v", resp.Routes[0])
@@ -24,13 +25,27 @@ func TestRegisterManagement(t *testing.T) {
if resp.Routes[1].Method != http.MethodPost || resp.Routes[1].Path != "/plugins/commandcode/usage" { if resp.Routes[1].Method != http.MethodPost || resp.Routes[1].Path != "/plugins/commandcode/usage" {
t.Errorf("Route 1 mismatch: %+v", resp.Routes[1]) t.Errorf("Route 1 mismatch: %+v", resp.Routes[1])
} }
wantOpencode := []struct{ method, path string }{
{http.MethodGet, "/plugins/commandcode/opencode/usage"},
{http.MethodPost, "/plugins/commandcode/opencode/usage"},
{http.MethodGet, "/plugins/commandcode/all"},
{http.MethodPost, "/plugins/commandcode/all"},
}
for i, w := range wantOpencode {
if resp.Routes[2+i].Method != w.method || resp.Routes[2+i].Path != w.path {
t.Errorf("Route %d mismatch: got %+v, want %s %s", 2+i, resp.Routes[2+i], w.method, w.path)
}
}
if len(resp.Resources) != 1 { if len(resp.Resources) != 1 {
t.Fatalf("len(Resources) = %d, want 1", len(resp.Resources)) t.Fatalf("len(Resources) = %d, want 1", len(resp.Resources))
} }
if resp.Resources[0].Path != "/quota" || resp.Resources[0].Menu != "Command Code 配额" { if resp.Resources[0].Path != "/quota" || resp.Resources[0].Menu != "用量配额" {
t.Errorf("Resource 0 mismatch: %+v", resp.Resources[0]) t.Errorf("Resource 0 mismatch: %+v", resp.Resources[0])
} }
if resp.Resources[0].Description != "Command Code + OpenCode Go 用量与限额卡片" {
t.Errorf("Resource Description mismatch: %+v", resp.Resources[0])
}
} }
func TestHandleManagement_QuotaResource(t *testing.T) { func TestHandleManagement_QuotaResource(t *testing.T) {
@@ -56,8 +71,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
t.Errorf("Content-Type = %v, want text/html", ct) t.Errorf("Content-Type = %v, want text/html", ct)
} }
bodyStr := string(resp.Body) bodyStr := string(resp.Body)
if !strings.Contains(bodyStr, "Command Code 配额") { if !strings.Contains(bodyStr, "用量配额") {
t.Errorf("Body does not contain expected title") t.Errorf("Body does not contain expected menu text 用量配额")
} }
} }
} }
@@ -150,3 +165,302 @@ func TestHandleManagement_PostUsage(t *testing.T) {
t.Errorf("MonthlyCredits = %v, want 666", usage.Credits.MonthlyCredits) t.Errorf("MonthlyCredits = %v, want 666", usage.Credits.MonthlyCredits)
} }
} }
const mockOpencodeUsageJSON = `{"usage":{
"rolling": {"status":"ok","percent":4, "resetsAt":"2026-09-17T06:58:53.171Z"},
"weekly": {"status":"ok","percent":46,"resetsAt":"2026-09-21T00:00:00.000Z"},
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
}}`
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
// (which would route it to the Command Code handler).
func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
var sawAuthHeader bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/usage" {
t.Errorf("unexpected path %s, want /usage (Command Code handler must not be hit)", r.URL.Path)
http.NotFound(w, r)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer sk-opencode-override" {
t.Errorf("Authorization = %q, want Bearer sk-opencode-override", got)
}
if r.Header.Get("Cookie") != "" {
t.Errorf("unexpected Cookie header on opencode request: %q", r.Header.Get("Cookie"))
}
sawAuthHeader = true
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
reqBody, _ := json.Marshal(map[string]string{
"opencode_api_key": "sk-opencode-override",
"opencode_api_base": ts.URL,
})
for _, tc := range []struct {
method string
body []byte
}{
{http.MethodPost, reqBody},
// GET with configured plugin config (no query override by design).
} {
t.Run(tc.method, func(t *testing.T) {
cfg := &PluginConfig{
OpenCodeAPIKey: "sk-configured",
OpenCodeAPIBase: ts.URL,
}
req := ManagementRequest{
Method: tc.method,
Path: "/v0/management/plugins/commandcode/opencode/usage",
Body: tc.body,
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
var usage OpenCodeFormattedUsageResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("unmarshal body error: %v", err)
}
if !usage.OK || usage.Provider != "opencode_go" {
t.Fatalf("unexpected response: ok=%v provider=%q", usage.OK, usage.Provider)
}
if usage.Windows.Rolling.Percent != 4 || usage.Windows.Weekly.Percent != 46 || usage.Windows.Monthly.Percent != 23 {
t.Errorf("windows percents = %v/%v/%v, want 4/46/23",
usage.Windows.Rolling.Percent, usage.Windows.Weekly.Percent, usage.Windows.Monthly.Percent)
}
if usage.Windows.Weekly.ResetInSeconds <= 0 {
t.Errorf("weekly reset_in_seconds = %d, want > 0", usage.Windows.Weekly.ResetInSeconds)
}
})
}
if !sawAuthHeader {
t.Fatal("upstream never received Authorization header")
}
}
// Regression: /plugins/commandcode/all must not be swallowed by the generic
// "/usage" suffix match nor miss its dedicated handler.
func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/usage" && r.Header.Get("Authorization") != "":
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
case r.URL.Path == "/internal/billing/credits":
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":888},"windowLimits":{"fiveHour":{"used":2,"cap":20}}}`))
case r.URL.Path == "/internal/usage/summary":
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 100}`))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{
SessionToken: "configured-token",
APIBase: ts.URL,
OpenCodeAPIKey: "sk-configured",
OpenCodeAPIBase: ts.URL,
}
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/all",
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
var all AllUsageResponse
if err := json.Unmarshal(resp.Body, &all); err != nil {
t.Fatalf("unmarshal body error: %v", err)
}
if !all.OK {
t.Fatal("expected ok=true when both providers succeed")
}
if len(all.CommandCode) == 0 || len(all.OpenCode) == 0 {
t.Fatalf("expected both provider payloads, got commandcode=%d bytes opencode=%d bytes",
len(all.CommandCode), len(all.OpenCode))
}
if len(all.Errors) != 0 {
t.Errorf("expected empty errors map, got %v", all.Errors)
}
var ccUsage FormattedUsageResponse
if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK {
t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage)
}
var ocUsage OpenCodeFormattedUsageResponse
if err := json.Unmarshal(all.OpenCode, &ocUsage); err != nil || !ocUsage.OK {
t.Errorf("opencode payload invalid: err=%v usage=%+v", err, ocUsage)
}
}
// Partial failure: one provider fails upstream → ok stays true, the failed
// provider's field is omitted and the error lands in the errors map.
func TestHandleManagement_AllUsage_PartialFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/usage" && r.Header.Get("Authorization") != "":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"upstream exploded"}`))
case r.URL.Path == "/internal/billing/credits":
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":888},"windowLimits":{"fiveHour":{"used":2,"cap":20}}}`))
case r.URL.Path == "/internal/usage/summary":
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 100}`))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
cfg := &PluginConfig{
SessionToken: "configured-token",
APIBase: ts.URL,
OpenCodeAPIKey: "sk-configured",
OpenCodeAPIBase: ts.URL,
}
req := ManagementRequest{
Method: http.MethodPost,
Path: "/plugins/commandcode/all",
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200 (partial failure), body=%s", resp.StatusCode, string(resp.Body))
}
var all AllUsageResponse
if err := json.Unmarshal(resp.Body, &all); err != nil {
t.Fatalf("unmarshal body error: %v", err)
}
if !all.OK {
t.Error("expected ok=true despite one provider failing")
}
if len(all.CommandCode) == 0 {
t.Error("expected successful commandcode payload to be present")
}
if _, present := all.Errors["opencode"]; !present {
t.Errorf("expected errors[opencode] to be set, got %v", all.Errors)
}
if !strings.Contains(all.Errors["opencode"], "opencode upstream returned 500") {
t.Errorf("errors[opencode] = %q, want it to mention 'opencode upstream returned 500'", all.Errors["opencode"])
}
// opencode field must be omitted (omitempty), not serialized as "null".
if strings.Contains(string(resp.Body), `"opencode":null`) {
t.Errorf("opencode field serialized as null: %s", string(resp.Body))
}
}
// All providers fail because credentials are missing → 400.
func TestHandleManagement_AllUsage_AllMissingConfig(t *testing.T) {
SetHostCaller(nil)
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/all",
}
resp, err := HandleManagement(context.Background(), req, nil)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("StatusCode = %d, want 400, body=%s", resp.StatusCode, string(resp.Body))
}
var all AllUsageResponse
if err := json.Unmarshal(resp.Body, &all); err != nil {
t.Fatalf("unmarshal body error: %v", err)
}
if all.OK {
t.Error("expected ok=false")
}
if _, present := all.Errors["commandcode"]; !present {
t.Errorf("expected errors[commandcode], got %v", all.Errors)
}
if _, present := all.Errors["opencode"]; !present {
t.Errorf("expected errors[opencode], got %v", all.Errors)
}
}
// Regression for the acceptance review finding: an upstream 400 passed
// through by executeUsageQuery must NOT be classified as a local
// configuration problem — all-upstream-failure must yield 502, not 400.
func TestHandleManagement_AllUsage_Upstream400NotMisclassified(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"bad request from upstream"}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
cfgYAML := []byte("session_token: testtoken\napi_base: " + ts.URL + "\nopencode_api_key: sk-test\nopencode_api_base: " + ts.URL + "\n")
cfg := NewPlugin()
if err := cfg.config.UpdateFromYAML(cfgYAML); err != nil {
t.Fatalf("UpdateFromYAML: %v", err)
}
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/all",
}
resp, err := HandleManagement(context.Background(), req, cfg.config)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("StatusCode = %d, want 502 (upstream 400 must not be misread as local missing config), body=%s", resp.StatusCode, string(resp.Body))
}
}
// Unknown path after the new routes still 404s.
func TestHandleManagement_UnknownPath(t *testing.T) {
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/nonsense",
}
resp, err := HandleManagement(context.Background(), req, nil)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("StatusCode = %d, want 404", resp.StatusCode)
}
}
+46 -6
View File
@@ -13,7 +13,7 @@ import (
const ( const (
PluginID = "commandcode" PluginID = "commandcode"
PluginName = "commandcode" PluginName = "commandcode"
PluginVersion = "0.2.2" PluginVersion = "0.3.0"
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"
@@ -22,9 +22,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"`
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.
@@ -33,8 +35,10 @@ 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"`
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)
@@ -49,6 +53,14 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
if tmp.APIBase != "" { if tmp.APIBase != "" {
c.APIBase = strings.TrimRight(tmp.APIBase, "/") c.APIBase = strings.TrimRight(tmp.APIBase, "/")
} }
if tmp.OpenCodeAPIKey != "" {
// OpenCode API key is a plain Bearer token; do not run it through
// ExtractSessionToken (that is Command Code cookie specific).
c.OpenCodeAPIKey = strings.TrimSpace(tmp.OpenCodeAPIKey)
}
if tmp.OpenCodeAPIBase != "" {
c.OpenCodeAPIBase = strings.TrimRight(tmp.OpenCodeAPIBase, "/")
}
if c.APIBase == "" { if c.APIBase == "" {
c.APIBase = DefaultAPIBase c.APIBase = DefaultAPIBase
} }
@@ -79,6 +91,24 @@ func (c *PluginConfig) GetAPIBase() string {
return c.APIBase return c.APIBase
} }
// GetOpenCodeAPIKey safely returns the OpenCode Go API key.
func (c *PluginConfig) GetOpenCodeAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.OpenCodeAPIKey
}
// GetOpenCodeAPIBase safely returns the OpenCode Go API base URL,
// falling back to DefaultOpenCodeAPIBase when unset.
func (c *PluginConfig) GetOpenCodeAPIBase() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.OpenCodeAPIBase == "" {
return DefaultOpenCodeAPIBase
}
return c.OpenCodeAPIBase
}
// Plugin encapsulates the Command Code plugin instance. // Plugin encapsulates the Command Code plugin instance.
type Plugin struct { type Plugin struct {
config *PluginConfig config *PluginConfig
@@ -148,6 +178,16 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
Type: "string", Type: "string",
Description: "Command Code API base URL (default: https://api.commandcode.ai)", Description: "Command Code API base URL (default: https://api.commandcode.ai)",
}, },
{
Name: "opencode_api_key",
Type: "string",
Description: "OpenCode Go API key (Bearer token used for https://opencode.ai/zen/go/v1/usage)",
},
{
Name: "opencode_api_base",
Type: "string",
Description: "OpenCode Go API base URL (default: https://opencode.ai/zen/go/v1)",
},
}, },
}, },
Capabilities: RegistrationCapability{ Capabilities: RegistrationCapability{
+40 -5
View File
@@ -47,15 +47,15 @@ api_base: "https://custom-api.commandcode.ai"
} }
// Verify config fields // Verify config fields
if len(reg.Metadata.ConfigFields) != 2 { if len(reg.Metadata.ConfigFields) != 4 {
t.Fatalf("ConfigFields len = %d, want 2", len(reg.Metadata.ConfigFields)) t.Fatalf("ConfigFields len = %d, want 4", 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"] { if !fieldNames["session_token"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_base"] {
t.Errorf("ConfigFields missing session_token or api_base: %+v", reg.Metadata.ConfigFields) t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields)
} }
// Verify config parsed // Verify config parsed
@@ -65,7 +65,6 @@ api_base: "https://custom-api.commandcode.ai"
if p.config.GetAPIBase() != "https://custom-api.commandcode.ai" { if p.config.GetAPIBase() != "https://custom-api.commandcode.ai" {
t.Errorf("APIBase = %q, want https://custom-api.commandcode.ai", p.config.GetAPIBase()) t.Errorf("APIBase = %q, want https://custom-api.commandcode.ai", p.config.GetAPIBase())
} }
// Test plugin.reconfigure // Test plugin.reconfigure
reconfYAML := []byte(` reconfYAML := []byte(`
session_token: "new-token-abc" session_token: "new-token-abc"
@@ -135,3 +134,39 @@ func TestEnvelopeError(t *testing.T) {
t.Errorf("env.Error = %+v", env.Error) t.Errorf("env.Error = %+v", env.Error)
} }
} }
func TestPluginConfig_OpenCode(t *testing.T) {
p := NewPlugin()
configYAML := []byte("opencode_api_key: \" sk-opencode-123 \"\nopencode_api_base: \"https://custom.oc.example/v1/\"\n")
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil {
t.Fatalf("handleMethod(plugin.register) error: %v", err)
}
if got := p.config.GetOpenCodeAPIKey(); got != "sk-opencode-123" {
t.Errorf("OpenCodeAPIKey = %q, want sk-opencode-123", got)
}
if got := p.config.GetOpenCodeAPIBase(); got != "https://custom.oc.example/v1" {
t.Errorf("OpenCodeAPIBase = %q, want https://custom.oc.example/v1 (trailing slash trimmed)", got)
}
// A Command Code cookie string must NOT be run through ExtractSessionToken.
cookieLike := []byte("opencode_api_key: \"sk-raw-bearer-value\"\n")
req2, _ := json.Marshal(LifecycleRequest{ConfigYAML: cookieLike})
if _, err := p.HandleMethod("plugin.reconfigure", req2); err != nil {
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
}
if got := p.config.GetOpenCodeAPIKey(); got != "sk-raw-bearer-value" {
t.Errorf("OpenCodeAPIKey = %q, want sk-raw-bearer-value (raw, no cookie extraction)", got)
}
// Empty config falls back to the default base.
empty := NewPlugin()
if got := empty.config.GetOpenCodeAPIBase(); got != DefaultOpenCodeAPIBase {
t.Errorf("default OpenCodeAPIBase = %q, want %q", got, DefaultOpenCodeAPIBase)
}
if got := empty.config.GetOpenCodeAPIKey(); got != "" {
t.Errorf("default OpenCodeAPIKey = %q, want empty", got)
}
}
+652 -37
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 配额与用量 - CLIProxyAPI</title> <title>用量配额 - Command Code + OpenCode Go - CLIProxyAPI</title>
<style> <style>
:root { :root {
--bg-page: #f8fafc; --bg-page: #f8fafc;
@@ -622,6 +622,157 @@ const QuotaPageHTML = `<!DOCTYPE html>
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
/* Tab Bar (Command Code | OpenCode Go | All) */
.tab-bar {
display: flex;
gap: 6px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 6px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
}
.tab-btn {
flex: 1;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.tab-btn:hover {
color: var(--text-main);
background: var(--bg-subtle);
}
.tab-btn.active {
background: var(--primary);
color: #fff;
}
.tab-section {
display: none;
}
.tab-section.active {
display: block;
}
/* OpenCode card status chip (reuses .status-badge variants) */
.oc-status-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
/* All tab: two provider cards side by side */
.all-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
@media (max-width: 768px) {
.all-grid {
grid-template-columns: 1fr;
}
}
.all-provider-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 12px;
}
.all-provider-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.all-provider-name {
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
.all-provider-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.all-summary-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
font-size: 13px;
color: var(--text-muted);
}
.all-summary-value {
font-weight: 700;
color: var(--text-main);
font-feature-settings: "tnum";
}
.all-grid .progress-track {
height: 8px;
}
/* Per-tab provider failure error cards */
.error-card {
display: none;
background: var(--danger-subtle);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 20px;
flex-direction: column;
gap: 8px;
}
.error-card.show {
display: flex;
}
.error-card-title {
color: var(--danger);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.error-card-msg {
color: var(--danger);
font-size: 13px;
word-break: break-all;
}
.form-hint {
font-size: 11px;
color: var(--text-dim);
}
/* Footer */ /* Footer */
.footer-bar { .footer-bar {
display: flex; display: flex;
@@ -662,11 +813,11 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
<div> <div>
<div class="brand-title"> <div class="brand-title">
Command Code 配额 用量配额 - Command Code + OpenCode Go
<span class="version-tag">v0.2.2</span> <span class="version-tag">v0.3.0</span>
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span> <span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
</div> </div>
<div class="brand-subtitle">CLIProxyAPI 实时限额与 Credits 用量监控</div> <div class="brand-subtitle">CLIProxyAPI 实时限额与用量监控 (Command Code + OpenCode Go)</div>
</div> </div>
</div> </div>
@@ -694,6 +845,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
</div> </div>
<!-- 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" data-tab="opencode">OpenCode Go</button>
<button type="button" class="tab-btn" data-tab="all">All</button>
</div>
<!-- Alert Message --> <!-- Alert Message -->
<div id="alertBox" class="alert alert-danger"> <div id="alertBox" class="alert alert-danger">
<svg width="18" height="18" 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="18" height="18" 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>
@@ -715,12 +873,29 @@ const QuotaPageHTML = `<!DOCTYPE html>
<label for="inputSessionToken">Command Code 会话 Token (Session Token 测试)</label> <label for="inputSessionToken">Command Code 会话 Token (Session Token 测试)</label>
<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">
<label for="inputOpenCodeKey">OpenCode Go API Key (测试覆盖)</label>
<input type="password" id="inputOpenCodeKey" class="form-control" placeholder="覆盖测试: sk-..." />
<span class="form-hint">仅测试覆盖用:值不持久化,仅当次请求生效</span>
</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;">
<button id="btnSaveConfig" class="btn btn-primary">保存并重新获取用量</button> <button id="btnSaveConfig" class="btn btn-primary">保存并重新获取用量</button>
</div> </div>
</div> </div>
<!-- Tab: Command Code -->
<div id="sectionCommandcode" class="tab-section active">
<div id="ccErrorCard" class="error-card">
<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>
<span>Command Code 查询失败</span>
</div>
<div id="ccErrorMsg" class="error-card-msg">-</div>
</div>
<div id="ccContent">
<!-- Overview Metrics --> <!-- Overview Metrics -->
<div class="metrics-grid"> <div class="metrics-grid">
<div class="metric-card"> <div class="metric-card">
@@ -847,18 +1022,137 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
</div> </div>
</div>
</div>
<!-- /Tab: Command Code -->
<!-- Tab: OpenCode Go -->
<div id="sectionOpencode" class="tab-section">
<div id="ocErrorCard" class="error-card">
<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>
<span>OpenCode Go 查询失败</span>
</div>
<div id="ocErrorMsg" class="error-card-msg">-</div>
</div>
<div id="ocContent">
<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>
<!-- /Tab: OpenCode Go -->
<!-- Tab: All -->
<div id="sectionAll" class="tab-section">
<div class="all-grid">
<div class="all-provider-card">
<div class="all-provider-head">
<span class="all-provider-name">Command Code</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 class="all-provider-card">
<div class="all-provider-head">
<span class="all-provider-name">OpenCode Go</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>
<!-- /Tab: All -->
<!-- Footer Status --> <!-- Footer Status -->
<div class="footer-bar"> <div class="footer-bar">
<div>最后同步时间: <span id="lastUpdated">-</span></div> <div>最后同步时间: <span id="lastUpdated">-</span></div>
<div class="footer-links"> <div class="footer-links">
<span id="authInfo">Provider: commandcode</span> <span id="authInfo">Provider: commandcode + opencode_go</span>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
(function () { (function () {
const USAGE_ENDPOINT = "/v0/management/plugins/commandcode/usage"; const ALL_ENDPOINT = "/v0/management/plugins/commandcode/all";
// Elements // Elements
const btnRefresh = document.getElementById("btnRefresh"); const btnRefresh = document.getElementById("btnRefresh");
@@ -869,12 +1163,29 @@ 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 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 statusBadge = document.getElementById("statusBadge");
const statusText = document.getElementById("statusText"); const statusText = document.getElementById("statusText");
const planBadge = document.getElementById("planBadge"); const planBadge = document.getElementById("planBadge");
// Tab sections and per-provider error cards
const ccContent = document.getElementById("ccContent");
const ccErrorCard = document.getElementById("ccErrorCard");
const ccErrorMsg = document.getElementById("ccErrorMsg");
const ocContent = document.getElementById("ocContent");
const ocErrorCard = document.getElementById("ocErrorCard");
const ocErrorMsg = document.getElementById("ocErrorMsg");
// All tab elements
const allBodyCommandcode = document.getElementById("allBodyCommandcode");
const allBadgeCommandcode = document.getElementById("allBadgeCommandcode");
const allBadgeTextCommandcode = document.getElementById("allBadgeTextCommandcode");
const allBodyOpencode = document.getElementById("allBodyOpencode");
const allBadgeOpencode = document.getElementById("allBadgeOpencode");
const allBadgeTextOpencode = document.getElementById("allBadgeTextOpencode");
const valMonthlyCredits = document.getElementById("valMonthlyCredits"); const valMonthlyCredits = document.getElementById("valMonthlyCredits");
const valOpensourceCredits = document.getElementById("valOpensourceCredits"); const valOpensourceCredits = document.getElementById("valOpensourceCredits");
const valTotalCredits = document.getElementById("valTotalCredits"); const valTotalCredits = document.getElementById("valTotalCredits");
@@ -904,11 +1215,43 @@ 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 monthlyTargetTime = null;
let fiveHourTargetTime = null; let fiveHourTargetTime = null;
let weeklyTargetTime = null; let weeklyTargetTime = null;
let timerInterval = null; let timerInterval = null;
// Per-provider render state (drives tab badge aggregation)
const providerState = {
commandcode: { level: "unknown", err: null, data: null },
opencode: { level: "unknown", err: null, data: null }
};
let activeTab = "commandcode";
let ocTargets = { rolling: null, weekly: null, monthly: null };
function getStoredManagementKey() { function getStoredManagementKey() {
if (inputMgmtKey.value.trim()) { if (inputMgmtKey.value.trim()) {
return inputMgmtKey.value.trim(); return inputMgmtKey.value.trim();
@@ -965,6 +1308,92 @@ const QuotaPageHTML = `<!DOCTYPE html>
return n < 10 ? "0" + n : n; return n < 10 ? "0" + n : n;
} }
function esc(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// Returns a clamped number in [0,100], or null when missing/invalid
function clampPercent(v) {
if (v === null || v === undefined || v === "") return null;
const n = Number(v);
if (!isFinite(n)) return null;
return Math.min(100, Math.max(0, n));
}
// Unknown status values never error: only explicit exceeded flag,
// status "exceeded", or percent >= 100 count as exceeded
function levelOf(pct, exceeded, status) {
if (exceeded || status === "exceeded" || (pct !== null && pct >= 100)) return "exceeded";
if (pct !== null && pct >= 80) return "warning";
return "online";
}
const LEVEL_RANK = { unknown: -1, online: 0, warning: 1, error: 2, exceeded: 3 };
const BADGE_TEXT = {
unknown: "正在检查...",
online: "正常运行 (Normal)",
warning: "配额紧张 (Warning)",
exceeded: "已达限额 (Exceeded)",
error: "查询错误"
};
function badgeVisualClass(level) {
// error reuses the danger/exceeded look
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) {
providerState[provider].level = level;
providerState[provider].err = null;
updateGlobalBadge();
}
function summaryRow(label, value) {
return '<div class="all-summary-row"><span class="all-summary-label">' + label + '</span><span class="all-summary-value">' + value + '</span></div>';
}
function miniBar(pct, level) {
const cls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
const w = pct === null ? 0 : pct;
return '<div class="progress-track"><div class="progress-bar' + cls + '" style="width:' + w + '%"></div></div>';
}
function showProviderError(provider, msg) {
providerState[provider].level = "error";
providerState[provider].err = msg;
providerState[provider].data = null;
if (provider === "commandcode") {
ccContent.style.display = "none";
ccErrorMsg.textContent = msg;
ccErrorCard.classList.add("show");
} else {
ocContent.style.display = "none";
ocErrorMsg.textContent = msg;
ocErrorCard.classList.add("show");
}
updateGlobalBadge();
}
function updateTimers() { function updateTimers() {
if (monthlyTargetTime) { if (monthlyTargetTime) {
timerMonthly.textContent = formatCountdown(monthlyTargetTime); timerMonthly.textContent = formatCountdown(monthlyTargetTime);
@@ -975,10 +1404,18 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (weeklyTargetTime) { if (weeklyTargetTime) {
timerWeekly.textContent = formatCountdown(weeklyTargetTime); timerWeekly.textContent = formatCountdown(weeklyTargetTime);
} }
for (const key in ocTargets) {
if (ocTargets[key]) {
ocTimerEls[key].textContent = formatCountdown(ocTargets[key]);
}
}
} }
function renderUsage(data) { function renderUsage(data) {
hideAlert(); providerState.commandcode.err = null;
providerState.commandcode.data = data;
ccContent.style.display = "";
ccErrorCard.classList.remove("show");
const credits = data.credits || (data.data && data.data.credits) || {}; const credits = data.credits || (data.data && data.data.credits) || {};
const limits = data.window_limits || (data.data && data.data.window_limits) || {}; const limits = data.window_limits || (data.data && data.data.window_limits) || {};
@@ -1051,56 +1488,192 @@ const QuotaPageHTML = `<!DOCTYPE html>
weeklyTargetTime = null; weeklyTargetTime = null;
} }
// Overall Status // Per-provider status; the header badge is aggregated in updateGlobalBadge()
let ccLevel;
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) { if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
statusBadge.className = "status-badge exceeded"; ccLevel = "exceeded";
statusText.textContent = "已达限额 (Exceeded)";
} else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) { } else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) {
statusBadge.className = "status-badge warning"; ccLevel = "warning";
statusText.textContent = "配额紧张 (Warning)";
} else { } else {
statusBadge.className = "status-badge online"; ccLevel = "online";
statusText.textContent = "正常运行 (Normal)";
}
const updatedAtStr = data.updated_at || (data.data && data.data.updated_at);
if (updatedAtStr) {
lastUpdated.textContent = new Date(updatedAtStr).toLocaleString();
} else {
lastUpdated.textContent = new Date().toLocaleString();
} }
setProviderStatus("commandcode", ccLevel);
updateTimers(); updateTimers();
} }
function renderOpencode(data) {
providerState.opencode.err = null;
providerState.opencode.data = data;
ocContent.style.display = "";
ocErrorCard.classList.remove("show");
const windows = data.windows || {};
const defs = [
{ key: "rolling", card: ocCardRolling, badge: ocBadgeRolling, bar: ocBarRolling, timer: ocTimerRolling, chip: ocStatusRolling, chipText: ocStatusTextRolling, w: windows.rolling },
{ key: "weekly", card: ocCardWeekly, badge: ocBadgeWeekly, bar: ocBarWeekly, timer: ocTimerWeekly, chip: ocStatusWeekly, chipText: ocStatusTextWeekly, w: windows.weekly },
{ key: "monthly", card: ocCardMonthly, badge: ocBadgeMonthly, bar: ocBarMonthly, timer: ocTimerMonthly, chip: ocStatusMonthly, chipText: ocStatusTextMonthly, w: windows.monthly }
];
let worst = "online";
const rank = { online: 0, warning: 1, exceeded: 2 };
defs.forEach(function (d) {
const w = d.w || {};
const pct = clampPercent(w.percent);
const level = levelOf(pct, w.exceeded, w.status);
if (rank[level] > rank[worst]) worst = level;
d.badge.textContent = pct === null ? "- %" : pct.toFixed(1) + " %";
d.bar.style.width = (pct === null ? 0 : pct) + "%";
d.bar.className = "progress-bar" + (level === "exceeded" ? " danger" : level === "warning" ? " warning" : "");
d.card.className = "quota-card" + (level === "exceeded" ? " is-exceeded" : "");
d.chip.className = "status-badge " + level;
d.chipText.textContent = level === "exceeded" ? "超限" : level === "warning" ? "紧张" : "正常";
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;都没有则显示 "-"
let target = null;
if (w.reset_at) {
const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) target = t;
}
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);
}
function renderAllTab() {
const cc = providerState.commandcode;
const oc = providerState.opencode;
// Command Code summary card
if (cc.err) {
allBadgeCommandcode.className = "status-badge exceeded";
allBadgeTextCommandcode.textContent = "查询错误";
allBodyCommandcode.innerHTML = '<div class="error-card-title">Command Code 查询失败</div><div class="error-card-msg">' + esc(cc.err) + '</div>';
} else if (cc.data) {
const data = cc.data;
const credits = data.credits || (data.data && data.data.credits) || {};
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
const plan = data.plan || (data.data && data.data.plan);
const planName = plan ? (plan.name || (typeof plan === "string" ? plan : "Unknown")) : "-";
const windowsDef = [
{ name: "Monthly", o: limits.monthly || {} },
{ name: "5-Hour", o: limits.five_hour || {} },
{ name: "Weekly", o: limits.weekly || {} }
];
let worstName = "-";
let worstPct = null;
let worstLevel = "online";
windowsDef.forEach(function (d) {
const pct = clampPercent(d.o.percentage);
const level = levelOf(pct, d.o.exceeded, "");
if (worstPct === null || (pct !== null && pct > worstPct) || level === "exceeded") {
worstName = d.name;
worstPct = pct === null ? 0 : pct;
worstLevel = level;
}
});
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
allBodyCommandcode.innerHTML =
summaryRow("Plan", esc(planName)) +
summaryRow("Total Credits", formatUSD(credits.total_credits)) +
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
miniBar(worstPct, worstLevel);
} else {
allBadgeCommandcode.className = "status-badge";
allBadgeTextCommandcode.textContent = "尚未加载";
allBodyCommandcode.innerHTML = '<div class="all-summary-value">尚未加载</div>';
}
// OpenCode Go summary card
if (oc.err) {
allBadgeOpencode.className = "status-badge exceeded";
allBadgeTextOpencode.textContent = "查询错误";
allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>';
} else if (oc.data) {
const data = oc.data;
const windows = data.windows || {};
const defs = [
{ name: "Rolling 5h", w: windows.rolling },
{ name: "Weekly", w: windows.weekly },
{ 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);
allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-";
allBodyOpencode.innerHTML = html;
} else {
allBadgeOpencode.className = "status-badge";
allBadgeTextOpencode.textContent = "尚未加载";
allBodyOpencode.innerHTML = '<div class="all-summary-value">尚未加载</div>';
}
}
function setActiveTab(tab, updateHash) {
activeTab = tab;
const tabBtns = document.querySelectorAll(".tab-btn");
for (let i = 0; i < tabBtns.length; i++) {
tabBtns[i].classList.toggle("active", tabBtns[i].getAttribute("data-tab") === tab);
}
document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode");
document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode");
document.getElementById("sectionAll").classList.toggle("active", tab === "all");
updateGlobalBadge();
if (updateHash) {
if (tab === "commandcode") {
history.replaceState(null, "", location.pathname + location.search);
} else {
location.hash = tab;
}
}
}
async function fetchUsage() { async function fetchUsage() {
refreshIcon.classList.add("spin"); refreshIcon.classList.add("spin");
btnRefresh.disabled = true; btnRefresh.disabled = true;
const mgmtKey = getStoredManagementKey(); const mgmtKey = getStoredManagementKey();
const overrideToken = inputSessionToken.value.trim(); const overrideToken = inputSessionToken.value.trim();
const overrideOpenCodeKey = inputOpenCodeKey.value.trim();
const headers = { const headers = {
"Accept": "application/json" "Accept": "application/json",
"Content-Type": "application/json"
}; };
if (mgmtKey) { if (mgmtKey) {
headers["Authorization"] = "Bearer " + mgmtKey; headers["Authorization"] = "Bearer " + mgmtKey;
headers["X-Management-Key"] = mgmtKey; headers["X-Management-Key"] = mgmtKey;
} }
let method = "GET"; // 整页只发一次 /all 请求,一次拿两个 provider;
let body = null; // 覆盖凭据仅在填写时才进 body,不持久化
const bodyObj = {};
if (overrideToken) { if (overrideToken) {
method = "POST"; bodyObj.session_token = overrideToken;
headers["Content-Type"] = "application/json"; }
body = JSON.stringify({ session_token: overrideToken }); if (overrideOpenCodeKey) {
bodyObj.opencode_api_key = overrideOpenCodeKey;
} }
try { try {
const res = await fetch(USAGE_ENDPOINT, { const res = await fetch(ALL_ENDPOINT, {
method: method, method: "POST",
headers: headers, headers: headers,
body: body body: JSON.stringify(bodyObj)
}); });
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
@@ -1112,19 +1685,46 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
const json = await res.json(); const json = await res.json();
if (!res.ok || json.ok === false) { if (!res.ok) {
const msg = json.error || (json.message ? json.message : "获取配额失败 (HTTP " + res.status + ")"); const msg = (json && (json.error || json.message)) || "获取配额失败 (HTTP " + res.status + ")";
showProviderError("commandcode", msg);
showProviderError("opencode", msg);
showAlert(msg); showAlert(msg);
statusBadge.className = "status-badge exceeded";
statusText.textContent = "查询错误";
return; return;
} }
renderUsage(json); // 部分失败不阻塞:缺失/失败 provider 在各自 tab 内渲染错误卡片
let anyOk = false;
if (json.commandcode && json.commandcode.ok !== false) {
renderUsage(json.commandcode);
anyOk = true;
} else {
showProviderError("commandcode", (json.errors && json.errors.commandcode) || "Command Code 查询失败");
}
if (json.opencode && json.opencode.ok !== false) {
renderOpencode(json.opencode);
anyOk = true;
} else {
showProviderError("opencode", (json.errors && json.errors.opencode) || "OpenCode Go 查询失败");
}
if (anyOk) {
hideAlert();
} else {
showAlert("所有数据源查询失败,请检查配置或凭据。");
}
const updatedStr = json.updated_at ||
(json.commandcode && json.commandcode.updated_at) ||
(json.opencode && json.opencode.updated_at);
lastUpdated.textContent = updatedStr ? new Date(updatedStr).toLocaleString() : new Date().toLocaleString();
renderAllTab();
updateTimers();
} catch (err) { } catch (err) {
showProviderError("commandcode", "网络或同源请求错误: " + err.message);
showProviderError("opencode", "网络或同源请求错误: " + err.message);
showAlert("网络或同源请求错误: " + err.message); showAlert("网络或同源请求错误: " + err.message);
statusBadge.className = "status-badge exceeded";
statusText.textContent = "连接失败";
} finally { } finally {
refreshIcon.classList.remove("spin"); refreshIcon.classList.remove("spin");
btnRefresh.disabled = false; btnRefresh.disabled = false;
@@ -1150,6 +1750,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
fetchUsage(); fetchUsage();
}); });
// Tab switching + hash persistence (#opencode / #all)
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
setActiveTab(btn.getAttribute("data-tab"), true);
});
});
// Init on load // Init on load
const initKey = localStorage.getItem("management_key") || localStorage.getItem("cpa_management_key"); const initKey = localStorage.getItem("management_key") || localStorage.getItem("cpa_management_key");
if (initKey) { if (initKey) {
@@ -1225,6 +1832,14 @@ const QuotaPageHTML = `<!DOCTYPE html>
timerInterval = setInterval(updateTimers, 1000); timerInterval = setInterval(updateTimers, 1000);
} }
// Restore tab from location.hash, then initial fetch
const initHash = location.hash.replace(/^#/, "");
if (initHash === "opencode" || initHash === "all") {
setActiveTab(initHash, false);
} else {
setActiveTab("commandcode", false);
}
// Initial fetch // Initial fetch
fetchUsage(); fetchUsage();
})(); })();
+57
View File
@@ -248,3 +248,60 @@ type FormattedUsageResponse struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// OpenCodeUsageResponse reflects GET {opencode_api_base}/usage from OpenCode Go.
type OpenCodeUsageResponse struct {
Usage OpenCodeUsageWindows `json:"usage"`
}
// OpenCodeUsageWindows carries the three usage windows returned by OpenCode Go.
type OpenCodeUsageWindows struct {
Rolling OpenCodeUsageWindow `json:"rolling"`
Weekly OpenCodeUsageWindow `json:"weekly"`
Monthly OpenCodeUsageWindow `json:"monthly"`
}
// OpenCodeUsageWindow represents one quota window from OpenCode Go.
// Percent is int in the observed upstream payload but parsed as float64 for tolerance.
type OpenCodeUsageWindow struct {
Status string `json:"status"`
Percent float64 `json:"percent"`
ResetsAt string `json:"resetsAt"` // RFC3339 UTC
}
// OpenCodeFormattedWindows is the formatted OpenCode Go window section.
type OpenCodeFormattedWindows struct {
Rolling OpenCodeFormattedWindow `json:"rolling"`
Weekly OpenCodeFormattedWindow `json:"weekly"`
Monthly OpenCodeFormattedWindow `json:"monthly"`
}
// OpenCodeFormattedWindow is one formatted OpenCode Go window.
type OpenCodeFormattedWindow struct {
Status string `json:"status"`
Percent float64 `json:"percent"`
Exceeded bool `json:"exceeded"`
ResetAt string `json:"reset_at"`
ResetInSeconds int64 `json:"reset_in_seconds"`
}
// OpenCodeFormattedUsageResponse is the formatted OpenCode Go usage payload.
type OpenCodeFormattedUsageResponse struct {
OK bool `json:"ok"`
Provider string `json:"provider"` // "opencode_go"
Windows OpenCodeFormattedWindows `json:"windows"`
UpdatedAt string `json:"updated_at"`
Error string `json:"error,omitempty"`
}
// AllUsageResponse aggregates both providers for /plugins/commandcode/all.
// 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
// respective FormattedUsageResponse / OpenCodeFormattedUsageResponse.
type AllUsageResponse struct {
OK bool `json:"ok"` // at least one provider succeeded
CommandCode json.RawMessage `json:"commandcode,omitempty"`
OpenCode json.RawMessage `json:"opencode,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
UpdatedAt string `json:"updated_at"`
}
+106 -13
View File
@@ -14,7 +14,8 @@ import (
) )
const ( const (
DefaultAPIBase = "https://api.commandcode.ai" DefaultAPIBase = "https://api.commandcode.ai"
DefaultOpenCodeAPIBase = "https://opencode.ai/zen/go/v1"
) )
// HTTPDoer abstracts HTTP requests for testing and fallback. // HTTPDoer abstracts HTTP requests for testing and fallback.
@@ -56,16 +57,24 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/")) url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
cookieValue := FormatSessionCookie(cleanToken) cookieValue := FormatSessionCookie(cleanToken)
headers := map[string][]string{
"Cookie": {cookieValue},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// doUpstreamRequest is the shared transport layer: it tries the host.http.do
// bridge first (when a host caller is registered) and falls back to net/http.
// Request semantics (method, URL, headers) are fully controlled by the caller.
func doUpstreamRequest(ctx context.Context, method, url string, headers map[string][]string, hostCallbackID string) ([]byte, int, error) {
// 1. Try host.http.do if hostCaller is configured // 1. Try host.http.do if hostCaller is configured
if hostCaller != nil { if hostCaller != nil {
reqPayload := HostHTTPRequest{ reqPayload := HostHTTPRequest{
Method: http.MethodGet, Method: method,
URL: url, URL: url,
Headers: map[string][]string{ Headers: headers,
"Cookie": {cookieValue},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
},
HostCallbackID: hostCallbackID, HostCallbackID: hostCallbackID,
} }
rawReq, errMarshal := json.Marshal(reqPayload) rawReq, errMarshal := json.Marshal(reqPayload)
@@ -97,13 +106,15 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
} }
// 2. Fallback to Go net/http client // 2. Fallback to Go net/http client
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) httpReq, errNew := http.NewRequestWithContext(ctx, method, url, nil)
if errNew != nil { if errNew != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("create HTTP request: %w", errNew) return nil, http.StatusInternalServerError, fmt.Errorf("create HTTP request: %w", errNew)
} }
httpReq.Header.Set("Cookie", cookieValue) for key, values := range headers {
httpReq.Header.Set("Accept", "application/json") for _, value := range values {
httpReq.Header.Set("User-Agent", fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)) httpReq.Header.Add(key, value)
}
}
res, errDo := defaultHTTPClient.Do(httpReq) res, errDo := defaultHTTPClient.Do(httpReq)
if errDo != nil { if errDo != nil {
@@ -131,6 +142,88 @@ func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hos
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID) return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
} }
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, http.StatusBadRequest, errors.New("missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request")
}
if apiBase == "" {
apiBase = DefaultOpenCodeAPIBase
}
url := strings.TrimRight(apiBase, "/") + "/usage"
headers := map[string][]string{
"Authorization": {"Bearer " + apiKey},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// ParseOpenCodeUsage parses OpenCode Go usage JSON into the formatted response.
// Unknown status values are tolerated; a resetsAt that fails to parse is not
// fatal (ResetAt stays empty and ResetInSeconds stays 0).
func ParseOpenCodeUsage(raw []byte, now time.Time) (*OpenCodeFormattedUsageResponse, error) {
if len(raw) == 0 {
return nil, errors.New("empty response body from upstream")
}
var upstream OpenCodeUsageResponse
if err := json.Unmarshal(raw, &upstream); err != nil {
return nil, fmt.Errorf("unmarshal opencode usage response: %w", err)
}
if now.IsZero() {
now = time.Now().UTC()
}
return &OpenCodeFormattedUsageResponse{
OK: true,
Provider: "opencode_go",
Windows: OpenCodeFormattedWindows{
Rolling: formatOpenCodeWindow(upstream.Usage.Rolling, now),
Weekly: formatOpenCodeWindow(upstream.Usage.Weekly, now),
Monthly: formatOpenCodeWindow(upstream.Usage.Monthly, now),
},
UpdatedAt: now.Format(time.RFC3339),
}, nil
}
// formatOpenCodeWindow formats a single OpenCode Go usage window.
func formatOpenCodeWindow(w OpenCodeUsageWindow, now time.Time) OpenCodeFormattedWindow {
percent := clampOpenCodePercent(w.Percent)
out := OpenCodeFormattedWindow{
Status: w.Status,
Percent: percent,
Exceeded: percent >= 100 || w.Status == "exceeded",
}
if w.ResetsAt != "" {
if t, err := time.Parse(time.RFC3339, w.ResetsAt); err == nil {
out.ResetAt = t.UTC().Format(time.RFC3339)
if diff := t.UTC().Sub(now); diff > 0 {
out.ResetInSeconds = int64(diff.Seconds())
}
}
// Parse failure is not fatal: ResetAt stays empty, ResetInSeconds stays 0.
}
return out
}
// clampOpenCodePercent clamps a percentage to [0, 100] with 2-decimal rounding.
func clampOpenCodePercent(p float64) float64 {
if p < 0 {
p = 0
}
if p > 100 {
p = 100
}
return math.Round(p*100) / 100
}
// 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) {
@@ -299,7 +392,7 @@ func formatMonthlyWindow(credits map[string]any, summary *UpstreamUsageSummaryRe
if out.Remaining < 0 { if out.Remaining < 0 {
out.Remaining = 0 out.Remaining = 0
} }
out.ResetAt = "" // 账单周期重置时间上游未提供 out.ResetAt = "" // 账单周期重置时间上游未提供
out.ResetInSeconds = 0 out.ResetInSeconds = 0
return out return out
} }
+310
View File
@@ -6,6 +6,7 @@ import (
"math" "math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -376,3 +377,312 @@ func TestPlanFromWindowLimits(t *testing.T) {
}) })
} }
} }
func TestParseOpenCodeUsage(t *testing.T) {
now := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
t.Run("normal payload from real upstream shape", func(t *testing.T) {
raw := []byte(`{"usage":{
"rolling": {"status":"ok","percent":4, "resetsAt":"2026-09-17T06:58:53.171Z"},
"weekly": {"status":"ok","percent":46,"resetsAt":"2026-09-21T00:00:00.000Z"},
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if !usage.OK || usage.Provider != "opencode_go" {
t.Fatalf("unexpected header: ok=%v provider=%q", usage.OK, usage.Provider)
}
if usage.UpdatedAt != "2026-09-16T12:00:00Z" {
t.Errorf("UpdatedAt = %q", usage.UpdatedAt)
}
rolling := usage.Windows.Rolling
if rolling.Percent != 4 || rolling.Status != "ok" || rolling.Exceeded {
t.Errorf("rolling = %+v", rolling)
}
if rolling.ResetAt != "2026-09-17T06:58:53Z" {
t.Errorf("rolling reset_at = %q", rolling.ResetAt)
}
if rolling.ResetInSeconds != 68333 {
t.Errorf("rolling reset_in_seconds = %d, want 68333", rolling.ResetInSeconds)
}
weekly := usage.Windows.Weekly
if weekly.Percent != 46 {
t.Errorf("weekly percent = %v, want 46", weekly.Percent)
}
if weekly.ResetAt != "2026-09-21T00:00:00Z" {
t.Errorf("weekly reset_at = %q, want 2026-09-21T00:00:00Z (.000Z tolerated)", weekly.ResetAt)
}
monthly := usage.Windows.Monthly
if monthly.Percent != 23 {
t.Errorf("monthly percent = %v, want 23", monthly.Percent)
}
})
t.Run("float percent", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":12.345,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if got := usage.Windows.Rolling.Percent; got != 12.35 { // Round(x*100)/100
t.Errorf("percent = %v, want 12.35", got)
}
})
t.Run("unknown status tolerated", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"weird-status","percent":50,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if got := usage.Windows.Rolling; got.Status != "weird-status" || got.Exceeded {
t.Errorf("rolling = %+v, want status kept and not exceeded", got)
}
})
t.Run("exceeded status", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"exceeded","percent":99,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if !usage.Windows.Rolling.Exceeded {
t.Error("expected Exceeded=true for status=exceeded")
}
})
t.Run("percent 100 exceeded", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":100,"resetsAt":""}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if !usage.Windows.Rolling.Exceeded {
t.Error("expected Exceeded=true for percent=100")
}
if usage.Windows.Rolling.ResetAt != "" || usage.Windows.Rolling.ResetInSeconds != 0 {
t.Errorf("expected empty reset fields, got %+v", usage.Windows.Rolling)
}
})
t.Run("percent above 100 clamped", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":150.5,"resetsAt":""}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if got := usage.Windows.Rolling.Percent; got != 100 {
t.Errorf("percent = %v, want 100 (clamped)", got)
}
if !usage.Windows.Rolling.Exceeded {
t.Error("expected Exceeded=true when clamped to 100")
}
})
t.Run("malformed resetsAt not fatal", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":5,"resetsAt":"not-a-timestamp"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage must not fail on bad resetsAt: %v", err)
}
if got := usage.Windows.Rolling; got.ResetAt != "" || got.ResetInSeconds != 0 {
t.Errorf("expected zero reset fields on parse failure, got %+v", got)
}
})
t.Run("missing windows tolerated as zero values", func(t *testing.T) {
raw := []byte(`{"usage":{}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if usage.Windows.Rolling.Percent != 0 {
t.Errorf("rolling percent = %v, want 0", usage.Windows.Rolling.Percent)
}
})
t.Run("empty body", func(t *testing.T) {
if _, err := ParseOpenCodeUsage(nil, now); err == nil {
t.Fatal("expected error for empty body")
}
})
t.Run("invalid JSON", func(t *testing.T) {
if _, err := ParseOpenCodeUsage([]byte(`not-json`), now); err == nil {
t.Fatal("expected error for invalid JSON")
}
})
}
func TestFetchOpenCodeUsageRaw_FallbackHTTP(t *testing.T) {
var sawAuth, sawUA, sawAccept string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/usage" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
sawAuth = r.Header.Get("Authorization")
sawUA = r.Header.Get("User-Agent")
sawAccept = r.Header.Get("Accept")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"usage":{"rolling":{"status":"ok","percent":4,"resetsAt":"2026-09-17T06:58:53.171Z"}}}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
body, status, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL, "sk-test-key", "")
if err != nil {
t.Fatalf("FetchOpenCodeUsageRaw error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if len(body) == 0 {
t.Fatal("expected non-empty body")
}
if sawAuth != "Bearer sk-test-key" {
t.Errorf("Authorization = %q, want Bearer sk-test-key", sawAuth)
}
if sawAccept != "application/json" {
t.Errorf("Accept = %q, want application/json", sawAccept)
}
if !strings.Contains(sawUA, "cliproxy-plugin-commandcode/") {
t.Errorf("User-Agent = %q, want cliproxy-plugin-commandcode/<version>", sawUA)
}
usage, errParse := ParseOpenCodeUsage(body, time.Time{})
if errParse != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", errParse)
}
if usage.Windows.Rolling.Percent != 4 {
t.Errorf("rolling percent = %v, want 4", usage.Windows.Rolling.Percent)
}
}
func TestFetchOpenCodeUsageRaw_BaseTrailingSlash(t *testing.T) {
requests := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != "/usage" {
t.Errorf("path = %q, want /usage (trailing slash trimmed)", r.URL.Path)
}
_, _ = w.Write([]byte(`{"usage":{"rolling":{"status":"ok","percent":1,"resetsAt":""}}}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
if _, _, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL+"/", "sk-key", ""); err != nil {
t.Fatalf("error: %v", err)
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
}
func TestFetchOpenCodeUsageRaw_MissingKey(t *testing.T) {
_, status, err := FetchOpenCodeUsageRaw(context.Background(), "", "", "")
if err == nil {
t.Fatal("expected error for missing key")
}
if status != http.StatusBadRequest {
t.Errorf("status = %d, want 400", status)
}
}
func TestFetchOpenCodeUsageRaw_EmptyKeyAfterTrim(t *testing.T) {
_, status, err := FetchOpenCodeUsageRaw(context.Background(), "", " ", "")
if err == nil {
t.Fatal("expected error for whitespace-only key")
}
if status != http.StatusBadRequest {
t.Errorf("status = %d, want 400", status)
}
}
func TestFetchOpenCodeUsageRaw_UpstreamNon200(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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})
}()
body, status, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL, "sk-bad", "")
if err != nil {
t.Fatalf("expected nil transport error for non-200 upstream, got %v", err)
}
if status != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", status)
}
if string(body) != `{"error":"invalid api key"}` {
t.Errorf("body = %q", string(body))
}
}
func TestFetchOpenCodeUsageRaw_HostCaller(t *testing.T) {
mockResponsePayload := []byte(`{"usage":{"rolling":{"status":"ok","percent":7,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
var sawMethod, sawURL string
var sawHeaders map[string][]string
SetHostCaller(func(method string, payload []byte) ([]byte, error) {
if method != "host.http.do" {
t.Errorf("method = %s, want host.http.do", method)
}
var req HostHTTPRequest
if err := json.Unmarshal(payload, &req); err != nil {
t.Fatalf("unmarshal HostHTTPRequest error: %v", err)
}
sawMethod, sawURL, sawHeaders = req.Method, req.URL, req.Headers
hostResp := HostHTTPResponse{
StatusCode: http.StatusOK,
Body: mockResponsePayload,
}
respJSON, _ := json.Marshal(hostResp)
return json.Marshal(Envelope{OK: true, Result: respJSON})
})
defer SetHostCaller(nil)
body, status, err := FetchOpenCodeUsageRaw(context.Background(), "https://opencode.example/v1", "sk-host-key", "cb-123")
if err != nil {
t.Fatalf("FetchOpenCodeUsageRaw with hostCaller error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if string(body) != string(mockResponsePayload) {
t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload))
}
if sawMethod != http.MethodGet {
t.Errorf("host request method = %s, want GET", sawMethod)
}
if sawURL != "https://opencode.example/v1/usage" {
t.Errorf("host request url = %s, want https://opencode.example/v1/usage", sawURL)
}
auth := sawHeaders["Authorization"]
if len(auth) == 0 || auth[0] != "Bearer sk-host-key" {
t.Errorf("host request Authorization = %v, want Bearer sk-host-key", auth)
}
}