mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 03:32:50 +08:00
Compare commits
9
Commits
61c50280b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7733552ee | ||
|
|
c2b8d89ba6 | ||
|
|
9d1884cf20 | ||
|
|
447ce65c9b | ||
|
|
4fdc9ad6c8 | ||
|
|
2f1b2fb821 | ||
|
|
3097d96a8e | ||
|
|
0a9213ec38 | ||
|
|
553226c96e |
@@ -7,15 +7,22 @@ else
|
||||
TARGET := commandcode.so
|
||||
endif
|
||||
|
||||
.PHONY: all build test clean lint
|
||||
.PHONY: all build test clean lint pagecheck
|
||||
|
||||
all: build
|
||||
all: build pagecheck
|
||||
|
||||
build:
|
||||
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:
|
||||
go test -v -race ./...
|
||||
$(MAKE) pagecheck
|
||||
|
||||
clean:
|
||||
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
- 支持在 `config.yaml` 配置或在配额页面上直接输入。
|
||||
- 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。
|
||||
4. **精确用量与双滑动窗口限额解析**:
|
||||
- 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。
|
||||
- 上游接口(v0.5.0+):配置 `commandcode_api_key`(Provider API key,长期凭据)时走 `GET https://api.commandcode.ai/alpha/billing/credits` 与 `/alpha/usage/summary`(Bearer 认证);否则回退 session cookie 查 `/internal/billing/credits`。
|
||||
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
|
||||
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
|
||||
5. **嵌入式纯单文件 QuotaCard 资源页**:
|
||||
@@ -132,10 +132,14 @@ plugins:
|
||||
commandcode:
|
||||
enabled: true
|
||||
priority: 1
|
||||
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
|
||||
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # v0.4.5 前唯一凭据;v0.5.0 起为可选回退
|
||||
commandcode_api_key: "user_YOUR_COMMANDCODE_PROVIDER_KEY" # v0.5.0+ 推荐:非空则用量查询走 /alpha 端点(Bearer),无需 session cookie
|
||||
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
|
||||
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # v0.3.0+ 可选,OpenCode Go 用量查询
|
||||
opencode_api_base: "https://opencode.ai/zen/go/v1" # v0.3.0+ 可选,默认为官方接口
|
||||
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+)
|
||||
# v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口
|
||||
opencode_api_keys:
|
||||
- "sk-KEY1..."
|
||||
- "sk-KEY2..."
|
||||
```
|
||||
|
||||
---
|
||||
@@ -212,18 +216,22 @@ plugins:
|
||||
|
||||
- **端点**:`GET /v0/management/plugins/commandcode/opencode/usage`(认证同上,仅读插件配置;凭据覆盖走 POST)
|
||||
- **端点**:`POST /v0/management/plugins/commandcode/opencode/usage`
|
||||
- **POST 请求体**:
|
||||
- **POST 请求体**(多 key 数组优先;scalar 为 v0.3.0 兼容):
|
||||
|
||||
```json
|
||||
{ "opencode_api_key": "sk-YOUR_TEMPORARY_KEY" }
|
||||
{ "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] }
|
||||
```
|
||||
|
||||
- **响应示例**:
|
||||
- **响应(v0.4.0+,逐 key 结果数组)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"provider": "opencode_go",
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "sk-L…KqYB",
|
||||
"ok": true,
|
||||
"windows": {
|
||||
"rolling": { "status": "ok", "percent": 4, "exceeded": false,
|
||||
"reset_at": "2026-09-17T06:58:53Z", "reset_in_seconds": 2520 },
|
||||
@@ -232,31 +240,47 @@ plugins:
|
||||
"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"
|
||||
"updated_at": "2026-09-16T12:00:00Z",
|
||||
"status_code": 200
|
||||
},
|
||||
{
|
||||
"key_id": "sk-U…PNHn",
|
||||
"ok": false,
|
||||
"updated_at": "2026-09-16T12:00:01Z",
|
||||
"status_code": 401,
|
||||
"error": "opencode upstream returned 401: check opencode_api_key"
|
||||
}
|
||||
],
|
||||
"updated_at": "2026-09-16T12:00:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
- `key_id` 为服务端脱敏标识(前4+…+后4),原始 key 永不出现在响应中;失败 key 无 `windows` 字段,单 key 失败不影响其他 key。
|
||||
- **HTTP 状态**:≥1 key 成功 → 200;key 全配但全失败 → 502;未配置任何 key → 400。
|
||||
|
||||
### 5. 管理 API: 聚合查询 (`all`)
|
||||
|
||||
- **端点**:`GET /v0/management/plugins/commandcode/all`(仅读插件配置)
|
||||
- **端点**:`POST /v0/management/plugins/commandcode/all`
|
||||
- **POST 请求体**(可只带其一):
|
||||
- **POST 请求体**(可只带其一;多 key 覆盖为数组):
|
||||
|
||||
```json
|
||||
{ "session_token": "...", "opencode_api_key": "sk-..." }
|
||||
{ "session_token": "...", "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] }
|
||||
```
|
||||
|
||||
- **部分失败语义**:HTTP 200 表示至少一个 provider 成功;失败 provider 记入 `errors`,其响应字段(`commandcode`/`opencode`)整个省略;全失败且为本地凭据缺失 → 400,全失败且为上游错误 → 502。
|
||||
- **部分失败语义**:HTTP 200 表示至少一个 provider(Command Code 或 ≥1 个 OpenCode key)成功;失败 provider 记入 `errors`,其响应字段整个省略;全失败且为本地凭据缺失 → 400,全失败且为上游错误 → 502。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"commandcode": { "ok": true, "plan": {...}, "credits": {...}, "window_limits": {...}, "updated_at": "..." },
|
||||
"opencode": { "ok": true, "provider": "opencode_go", "windows": {...}, "updated_at": "..." },
|
||||
"opencode": { "ok": true, "provider": "opencode_go", "keys": [ ...同上... ], "updated_at": "..." },
|
||||
"updated_at": "2026-09-16T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
> **v0.4.0 breaking note**:`opencode` 字段从单 key 对象变为 `{ok, provider, keys[], updated_at}` 多 key 结构(keys[].windows 为 v0.3.0 原窗口结构)。唯一消费方是同仓 QuotaCard 资源页,已同版本同步更新。
|
||||
|
||||
---
|
||||
|
||||
## 用量数据结构说明
|
||||
@@ -275,9 +299,10 @@ plugins:
|
||||
| `window_limits.five_hour.reset_in_seconds`| `int64` | 距离 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) |
|
||||
| `keys[].key_id` | `string` | 服务端脱敏 key 标识(前4+…+后4),原始 key 不出响应 |
|
||||
| `keys[].ok` | `bool` | 该 key 查询是否成功(单 key 401 隔离) |
|
||||
| `keys[].windows.<...>` | `object` | 成功 key 的三窗口指标(结构同上;失败 key 无此字段) |
|
||||
| `keys[].status_code` / `error` | `int` / `string` | 该 key 上游 HTTP 状态与失败原因 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+191
-103
@@ -157,7 +157,15 @@ func handleGetUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
|
||||
apiBase = cfg.GetAPIBase()
|
||||
}
|
||||
|
||||
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
||||
// commandcode_api_key is NOT overridable via query parameters (same
|
||||
// secrets-out-of-URLs policy as the OpenCode handler): the plugin
|
||||
// config is the only credential source on GET.
|
||||
apiKey := ""
|
||||
if cfg != nil {
|
||||
apiKey = cfg.GetCommandCodeAPIKey()
|
||||
}
|
||||
|
||||
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
|
||||
}
|
||||
|
||||
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
||||
@@ -165,6 +173,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
||||
SessionToken string `json:"session_token"`
|
||||
Token string `json:"token"`
|
||||
APIBase string `json:"api_base"`
|
||||
CommandCodeAPIKey string `json:"commandcode_api_key"`
|
||||
}
|
||||
|
||||
if len(req.Body) > 0 {
|
||||
@@ -176,6 +185,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
||||
sessionToken = body.Token
|
||||
}
|
||||
apiBase := body.APIBase
|
||||
apiKey := body.CommandCodeAPIKey
|
||||
|
||||
// Fallback to plugin config if body didn't specify
|
||||
if sessionToken == "" && cfg != nil {
|
||||
@@ -184,12 +194,22 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
||||
if apiBase == "" && cfg != nil {
|
||||
apiBase = cfg.GetAPIBase()
|
||||
}
|
||||
|
||||
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
||||
if apiKey == "" && cfg != nil {
|
||||
apiKey = cfg.GetCommandCodeAPIKey()
|
||||
}
|
||||
|
||||
func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackID string) (ManagementResponse, error) {
|
||||
if strings.TrimSpace(sessionToken) == "" {
|
||||
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
|
||||
}
|
||||
|
||||
func executeUsageQuery(ctx context.Context, apiBase, apiKey, sessionToken, hostCallbackID string) (ManagementResponse, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
|
||||
// Credential priority: commandcode_api_key non-empty → /alpha + Bearer
|
||||
// (Provider API key, no cookie); otherwise session_token → /internal
|
||||
// + Cookie (legacy fallback). Neither present → 400 with the
|
||||
// "session_token is required" prefix (isLocalCredentialError in the
|
||||
// /all aggregate depends on this message).
|
||||
if strings.TrimSpace(sessionToken) == "" && apiKey == "" {
|
||||
resBytes, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
|
||||
@@ -203,7 +223,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
||||
}, nil
|
||||
}
|
||||
|
||||
raw, statusCode, errFetch := FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
|
||||
var raw []byte
|
||||
var statusCode int
|
||||
var errFetch error
|
||||
if apiKey != "" {
|
||||
raw, statusCode, errFetch = FetchCommandCodeCreditsAlphaRaw(ctx, apiBase, apiKey, hostCallbackID)
|
||||
} else {
|
||||
raw, statusCode, errFetch = FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
|
||||
}
|
||||
if errFetch != nil {
|
||||
resBytes, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
@@ -223,12 +250,19 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
resBytes, _ := json.Marshal(map[string]any{
|
||||
payload := map[string]any{
|
||||
"ok": false,
|
||||
"status_code": statusCode,
|
||||
"error": "upstream returned non-200 status",
|
||||
"body": string(raw),
|
||||
})
|
||||
}
|
||||
if apiKey != "" {
|
||||
// Alpha path: do NOT echo the upstream body; point at the
|
||||
// configured Provider API key instead.
|
||||
payload["error"] = fmt.Sprintf("commandcode upstream returned %d: check commandcode_api_key", statusCode)
|
||||
} else {
|
||||
payload["error"] = "upstream returned non-200 status"
|
||||
payload["body"] = string(raw)
|
||||
}
|
||||
resBytes, _ := json.Marshal(payload)
|
||||
return ManagementResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: map[string][]string{
|
||||
@@ -240,7 +274,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
||||
|
||||
// Fetch billing-period (monthly) usage totals; non-fatal if unavailable.
|
||||
var summary *UpstreamUsageSummaryResponse
|
||||
if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
||||
if apiKey != "" {
|
||||
if sumRaw, sumStatus, sumErr := FetchCommandCodeUsageSummaryAlphaRaw(ctx, apiBase, apiKey, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
||||
var parsed UpstreamUsageSummaryResponse
|
||||
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
|
||||
summary = &parsed
|
||||
}
|
||||
}
|
||||
} else if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
||||
var parsed UpstreamUsageSummaryResponse
|
||||
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
|
||||
summary = &parsed
|
||||
@@ -273,36 +314,91 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
||||
}
|
||||
|
||||
// handleOpenCodeUsage serves GET/POST /plugins/commandcode/opencode/usage.
|
||||
// Credentials can be overridden via POST body only (opencode_api_key / api_key);
|
||||
// GET queries are read-only against the plugin config — query parameter
|
||||
// overrides are intentionally not supported to keep secrets out of URLs.
|
||||
// Credentials can be overridden via POST body only (opencode_api_keys list /
|
||||
// opencode_api_key scalar); GET queries are read-only against the plugin
|
||||
// config — query parameter overrides are intentionally not supported to keep
|
||||
// secrets out of URLs.
|
||||
//
|
||||
// The response is the multi-key OpenCodeMultiKeyResponse envelope (v0.4.0):
|
||||
// >=1 key succeeded → 200; keys configured but all upstream-failed → 502; no
|
||||
// keys configured at all → 400 with a top-level "no opencode api keys
|
||||
// configured ..." error.
|
||||
func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
||||
apiKey := ""
|
||||
apiBase := ""
|
||||
var keys []string
|
||||
|
||||
if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 {
|
||||
if req.Method == http.MethodPost && len(req.Body) > 0 {
|
||||
var body struct {
|
||||
OpenCodeAPIKeys []string `json:"opencode_api_keys"`
|
||||
OpenCodeAPIKey string `json:"opencode_api_key"`
|
||||
APIKey string `json:"api_key"`
|
||||
OpenCodeAPIBase string `json:"opencode_api_base"`
|
||||
}
|
||||
_ = json.Unmarshal(req.Body, &body)
|
||||
apiKey = body.OpenCodeAPIKey
|
||||
if apiKey == "" {
|
||||
apiKey = body.APIKey
|
||||
keys = normalizeOpenCodeKeys(body.OpenCodeAPIKeys)
|
||||
if len(keys) == 0 {
|
||||
single := strings.TrimSpace(body.OpenCodeAPIKey)
|
||||
if single == "" {
|
||||
single = strings.TrimSpace(body.APIKey)
|
||||
}
|
||||
if single != "" {
|
||||
keys = []string{single}
|
||||
}
|
||||
}
|
||||
apiBase = body.OpenCodeAPIBase
|
||||
}
|
||||
|
||||
// Fallback to plugin config
|
||||
if apiKey == "" && cfg != nil {
|
||||
apiKey = cfg.GetOpenCodeAPIKey()
|
||||
if len(keys) == 0 && cfg != nil {
|
||||
keys = cfg.GetOpenCodeAPIKeys()
|
||||
}
|
||||
if apiBase == "" && cfg != nil {
|
||||
apiBase = cfg.GetOpenCodeAPIBase()
|
||||
}
|
||||
|
||||
return handleOpenCodeUsageWithKey(ctx, apiBase, apiKey, req.HostCallbackID)
|
||||
now := time.Now().UTC()
|
||||
if len(keys) == 0 {
|
||||
resBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{
|
||||
OK: false,
|
||||
Provider: "opencode_go",
|
||||
Keys: []OpenCodeKeyResult{},
|
||||
UpdatedAt: now.Format(time.RFC3339),
|
||||
Error: "no opencode api keys configured. Configure opencode_api_keys (YAML list) or opencode_api_key in the plugin config, or pass opencode_api_keys in the POST body",
|
||||
})
|
||||
return ManagementResponse{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: resBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
results := QueryOpenCodeKeys(ctx, apiBase, keys, req.HostCallbackID)
|
||||
succeeded := 0
|
||||
for _, r := range results {
|
||||
if r.OK {
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
|
||||
statusCode := http.StatusOK
|
||||
if succeeded == 0 {
|
||||
statusCode = http.StatusBadGateway
|
||||
}
|
||||
resBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{
|
||||
OK: succeeded > 0,
|
||||
Provider: "opencode_go",
|
||||
Keys: results,
|
||||
UpdatedAt: now.Format(time.RFC3339),
|
||||
})
|
||||
return ManagementResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: resBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleAllUsage serves GET/POST /plugins/commandcode/all: it queries both
|
||||
@@ -314,24 +410,36 @@ func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *Plugin
|
||||
// all failed due to upstream errors → 502.
|
||||
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
||||
sessionToken := ""
|
||||
opencodeKey := ""
|
||||
commandcodeAPIKey := ""
|
||||
opencodeKeys := []string{}
|
||||
|
||||
if strings.EqualFold(strings.ToUpper(strings.TrimSpace(req.Method)), http.MethodPost) && len(req.Body) > 0 {
|
||||
if req.Method == http.MethodPost && len(req.Body) > 0 {
|
||||
var body struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
CommandCodeAPIKey string `json:"commandcode_api_key"`
|
||||
OpencodeAPIKeys []string `json:"opencode_api_keys"`
|
||||
OpencodeAPIKey string `json:"opencode_api_key"`
|
||||
}
|
||||
_ = json.Unmarshal(req.Body, &body)
|
||||
sessionToken = body.SessionToken
|
||||
opencodeKey = body.OpencodeAPIKey
|
||||
commandcodeAPIKey = body.CommandCodeAPIKey
|
||||
opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys)
|
||||
if len(opencodeKeys) == 0 {
|
||||
if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" {
|
||||
opencodeKeys = []string{single}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to plugin config
|
||||
if sessionToken == "" && cfg != nil {
|
||||
sessionToken = cfg.GetSessionToken()
|
||||
}
|
||||
if opencodeKey == "" && cfg != nil {
|
||||
opencodeKey = cfg.GetOpenCodeAPIKey()
|
||||
if commandcodeAPIKey == "" && cfg != nil {
|
||||
commandcodeAPIKey = cfg.GetCommandCodeAPIKey()
|
||||
}
|
||||
if len(opencodeKeys) == 0 && cfg != nil {
|
||||
opencodeKeys = cfg.GetOpenCodeAPIKeys()
|
||||
}
|
||||
apiBase := ""
|
||||
if cfg != nil {
|
||||
@@ -350,7 +458,7 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
|
||||
succeeded := 0
|
||||
|
||||
// Provider 1: Command Code (reuses executeUsageQuery).
|
||||
ccResp, _ := executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
||||
ccResp, _ := executeUsageQuery(ctx, apiBase, commandcodeAPIKey, sessionToken, req.HostCallbackID)
|
||||
if ccResp.StatusCode == http.StatusOK {
|
||||
resp.CommandCode = ccResp.Body
|
||||
succeeded++
|
||||
@@ -364,25 +472,34 @@ func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 2: OpenCode Go (same classification via isLocalCredentialError,
|
||||
// not by HTTP 400 alone: upstream 4xx may be passed through and must not
|
||||
// be misclassified as a local configuration problem).
|
||||
if strings.TrimSpace(opencodeKey) != "" {
|
||||
ocResp, _ := handleOpenCodeUsageWithKey(ctx, ocAPIBase, opencodeKey, req.HostCallbackID)
|
||||
if ocResp.StatusCode == http.StatusOK {
|
||||
resp.OpenCode = ocResp.Body
|
||||
// Provider 2: OpenCode Go, one sequential query per configured key
|
||||
// (v0.4.0). >=1 key success counts the provider as successful and the
|
||||
// multi-key payload is inlined; keys configured but all failed is an
|
||||
// upstream failure (a configured-but-invalid key is NOT a local config
|
||||
// problem); zero keys configured is a local missing-credential error.
|
||||
if len(opencodeKeys) > 0 {
|
||||
results := QueryOpenCodeKeys(ctx, ocAPIBase, opencodeKeys, req.HostCallbackID)
|
||||
succeededKeys := 0
|
||||
for _, r := range results {
|
||||
if r.OK {
|
||||
succeededKeys++
|
||||
}
|
||||
}
|
||||
if succeededKeys > 0 {
|
||||
ocBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{
|
||||
OK: true,
|
||||
Provider: "opencode_go",
|
||||
Keys: results,
|
||||
UpdatedAt: now.Format(time.RFC3339),
|
||||
})
|
||||
resp.OpenCode = ocBytes
|
||||
succeeded++
|
||||
} else {
|
||||
ocErr := extractErrorResponseMessage(ocResp.Body)
|
||||
errs["opencode"] = ocErr
|
||||
if isLocalCredentialError(ocErr) {
|
||||
localMissing++
|
||||
} else {
|
||||
errs["opencode"] = fmt.Sprintf("all %d opencode keys failed", len(opencodeKeys))
|
||||
upstreamFailed++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs["opencode"] = "missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request body"
|
||||
errs["opencode"] = "no opencode api keys configured. Configure opencode_api_keys (YAML list) or opencode_api_key in the plugin config, or pass opencode_api_keys in the POST body"
|
||||
localMissing++
|
||||
}
|
||||
|
||||
@@ -419,6 +536,7 @@ func isLocalCredentialError(msg string) bool {
|
||||
for _, prefix := range []string{
|
||||
"session_token is required",
|
||||
"opencode_api_key is required",
|
||||
"no opencode api keys configured",
|
||||
} {
|
||||
if strings.HasPrefix(msg, prefix) {
|
||||
return true
|
||||
@@ -427,81 +545,51 @@ func isLocalCredentialError(msg string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// handleOpenCodeUsageWithKey runs the OpenCode usage query with an explicit
|
||||
// credential, shared by handleOpenCodeUsage and handleAllUsage.
|
||||
func handleOpenCodeUsageWithKey(ctx context.Context, apiBase, apiKey, hostCallbackID string) (ManagementResponse, error) {
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
resBytes, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
"error": "opencode_api_key is required. Configure opencode_api_key in plugin config or pass it in the request body",
|
||||
})
|
||||
return ManagementResponse{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: resBytes,
|
||||
}, nil
|
||||
// queryOpenCodeKey runs the OpenCode Go usage query for a single API key and
|
||||
// returns a typed per-key result, shared by handleOpenCodeUsage and
|
||||
// handleAllUsage (via QueryOpenCodeKeys). The handler layer is responsible
|
||||
// for marshaling the aggregate response and picking the HTTP status code.
|
||||
func queryOpenCodeKey(ctx context.Context, apiBase, key, hostCallbackID string) (OpenCodeKeyResult, error) {
|
||||
now := time.Now().UTC()
|
||||
res := OpenCodeKeyResult{
|
||||
KeyID: MaskAPIKey(key),
|
||||
UpdatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, apiKey, hostCallbackID)
|
||||
if strings.TrimSpace(key) == "" {
|
||||
res.StatusCode = http.StatusBadRequest
|
||||
res.Error = "opencode_api_key is required. Configure opencode_api_keys in plugin config or pass it in the request"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, key, hostCallbackID)
|
||||
if errFetch != nil {
|
||||
errMsg := fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error())
|
||||
resBytes, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
"status_code": statusCode,
|
||||
"error": errMsg,
|
||||
})
|
||||
if statusCode == 0 || statusCode == http.StatusOK {
|
||||
statusCode = http.StatusBadGateway
|
||||
}
|
||||
return ManagementResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: resBytes,
|
||||
}, nil
|
||||
res.StatusCode = statusCode
|
||||
res.Error = fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error())
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
resBytes, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
"status_code": statusCode,
|
||||
"error": fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode),
|
||||
})
|
||||
return ManagementResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: resBytes,
|
||||
}, nil
|
||||
res.StatusCode = statusCode
|
||||
res.Error = fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
usage, errParse := ParseOpenCodeUsage(raw, time.Now().UTC())
|
||||
usage, errParse := ParseOpenCodeUsage(raw, now)
|
||||
if errParse != nil {
|
||||
resBytes, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
"error": "failed to parse opencode upstream usage: " + errParse.Error(),
|
||||
})
|
||||
return ManagementResponse{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: resBytes,
|
||||
}, nil
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
res.Error = "failed to parse opencode upstream usage: " + errParse.Error()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
resBytes, _ := json.Marshal(usage)
|
||||
return ManagementResponse{
|
||||
StatusCode: http.StatusOK,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: resBytes,
|
||||
}, nil
|
||||
res.OK = true
|
||||
res.StatusCode = http.StatusOK
|
||||
res.Windows = &usage.Windows
|
||||
res.UpdatedAt = usage.UpdatedAt
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// extractErrorResponseMessage pulls the "error" field out of a JSON error body.
|
||||
|
||||
+710
-10
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -74,6 +75,9 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
|
||||
if !strings.Contains(bodyStr, "用量配额") {
|
||||
t.Errorf("Body does not contain expected menu text 用量配额")
|
||||
}
|
||||
if !strings.Contains(bodyStr, "v0.5.0") {
|
||||
t.Errorf("Body does not contain version badge v0.5.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +176,24 @@ const mockOpencodeUsageJSON = `{"usage":{
|
||||
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
|
||||
}}`
|
||||
|
||||
// mockOpencodeUsageJSONFuture returns the same usage envelope but with reset
|
||||
// timestamps relative to now. The hardcoded dates in mockOpencodeUsageJSON
|
||||
// eventually fall into the past (weekly 2026-09-21 did), which makes
|
||||
// weekly.ResetInSeconds = 0 and breaks the `want > 0` assertion in
|
||||
// TestHandleManagement_OpencodeUsageRoute. Use this fixture for tests that
|
||||
// assert positive reset_in_seconds.
|
||||
func mockOpencodeUsageJSONFuture() string {
|
||||
now := time.Now().UTC()
|
||||
ts := func(d time.Duration) string {
|
||||
return now.Add(d).Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
return fmt.Sprintf(`{"usage":{
|
||||
"rolling": {"status":"ok","percent":4, "resetsAt":"%s"},
|
||||
"weekly": {"status":"ok","percent":46,"resetsAt":"%s"},
|
||||
"monthly": {"status":"ok","percent":23,"resetsAt":"%s"}
|
||||
}}`, ts(2*time.Hour), ts(72*time.Hour), ts(30*24*time.Hour))
|
||||
}
|
||||
|
||||
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
|
||||
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
|
||||
// (which would route it to the Command Code handler).
|
||||
@@ -191,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
|
||||
}
|
||||
sawAuthHeader = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
|
||||
_, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
@@ -230,19 +252,30 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var usage OpenCodeFormattedUsageResponse
|
||||
// v0.4.0: the response is the multi-key envelope even for a single key.
|
||||
var usage OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||
t.Fatalf("unmarshal body error: %v", err)
|
||||
}
|
||||
if !usage.OK || usage.Provider != "opencode_go" {
|
||||
t.Fatalf("unexpected response: ok=%v provider=%q", usage.OK, usage.Provider)
|
||||
}
|
||||
if usage.Windows.Rolling.Percent != 4 || usage.Windows.Weekly.Percent != 46 || usage.Windows.Monthly.Percent != 23 {
|
||||
t.Errorf("windows percents = %v/%v/%v, want 4/46/23",
|
||||
usage.Windows.Rolling.Percent, usage.Windows.Weekly.Percent, usage.Windows.Monthly.Percent)
|
||||
if len(usage.Keys) != 1 || !usage.Keys[0].OK {
|
||||
t.Fatalf("expected exactly one successful key, got %+v", usage.Keys)
|
||||
}
|
||||
if usage.Windows.Weekly.ResetInSeconds <= 0 {
|
||||
t.Errorf("weekly reset_in_seconds = %d, want > 0", usage.Windows.Weekly.ResetInSeconds)
|
||||
if usage.Keys[0].Windows == nil {
|
||||
t.Fatal("keys[0].windows = nil, want non-nil on success")
|
||||
}
|
||||
if usage.Keys[0].Windows.Rolling.Percent != 4 || usage.Keys[0].Windows.Weekly.Percent != 46 || usage.Keys[0].Windows.Monthly.Percent != 23 {
|
||||
t.Errorf("windows percents = %v/%v/%v, want 4/46/23",
|
||||
usage.Keys[0].Windows.Rolling.Percent, usage.Keys[0].Windows.Weekly.Percent, usage.Keys[0].Windows.Monthly.Percent)
|
||||
}
|
||||
if usage.Keys[0].Windows.Weekly.ResetInSeconds <= 0 {
|
||||
t.Errorf("weekly reset_in_seconds = %d, want > 0", usage.Keys[0].Windows.Weekly.ResetInSeconds)
|
||||
}
|
||||
// The raw key from the POST body must never appear in the response.
|
||||
if strings.Contains(string(resp.Body), "sk-opencode-override") && tc.method == http.MethodPost {
|
||||
t.Errorf("response leaks the raw override key: %s", string(resp.Body))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -252,6 +285,175 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// /opencode/usage status matrix (v0.4.0): >=1 key success → 200; one success
|
||||
// + one 401 → 200 with keys[1].ok=false and no windows; all 401 → 502; no
|
||||
// keys configured → 400 with the "no opencode api keys configured" prefix.
|
||||
func TestHandleManagement_OpencodeUsage_MultiKeyMatrix(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("Authorization") {
|
||||
case "Bearer sk-good-AAAA", "Bearer sk-good-ZZZZ":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
|
||||
default:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
postKeys := func(keys ...string) ManagementRequest {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"opencode_api_keys": keys,
|
||||
"opencode_api_base": ts.URL,
|
||||
})
|
||||
return ManagementRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/plugins/commandcode/opencode/usage",
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("both keys succeed → 200", func(t *testing.T) {
|
||||
resp, err := HandleManagement(context.Background(), postKeys("sk-good-AAAA", "sk-good-ZZZZ"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var usage OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if !usage.OK || len(usage.Keys) != 2 || !usage.Keys[0].OK || !usage.Keys[1].OK {
|
||||
t.Errorf("unexpected response: %+v", usage)
|
||||
}
|
||||
if usage.Error != "" {
|
||||
t.Errorf("top-level error = %q, want empty when keys are configured", usage.Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one success one 401 → 200 with failed key isolated", func(t *testing.T) {
|
||||
resp, err := HandleManagement(context.Background(), postKeys("sk-good-AAAA", "sk-bad-BBBB"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("StatusCode = %d, want 200 (>=1 key succeeded), body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var usage OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if !usage.OK || len(usage.Keys) != 2 {
|
||||
t.Fatalf("unexpected response: %+v", usage)
|
||||
}
|
||||
if !usage.Keys[0].OK || usage.Keys[0].Windows == nil {
|
||||
t.Errorf("keys[0] = %+v, want ok with windows", usage.Keys[0])
|
||||
}
|
||||
if usage.Keys[1].OK || usage.Keys[1].Windows != nil {
|
||||
t.Errorf("keys[1] = %+v, want not-ok with nil windows", usage.Keys[1])
|
||||
}
|
||||
if usage.Keys[1].StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("keys[1].status_code = %d, want 401", usage.Keys[1].StatusCode)
|
||||
}
|
||||
// windows must be omitted from the JSON for the failed key, not
|
||||
// serialized as null or a zero-value struct.
|
||||
var raw struct {
|
||||
Keys []struct {
|
||||
Windows json.RawMessage `json:"windows"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &raw); err != nil {
|
||||
t.Fatalf("unmarshal raw error: %v", err)
|
||||
}
|
||||
if len(raw.Keys[1].Windows) != 0 {
|
||||
t.Errorf("keys[1].windows in JSON = %s, want omitted", string(raw.Keys[1].Windows))
|
||||
}
|
||||
if !strings.Contains(usage.Keys[1].Error, "opencode upstream returned 401") {
|
||||
t.Errorf("keys[1].error = %q, want upstream 401 mention", usage.Keys[1].Error)
|
||||
}
|
||||
if strings.Contains(string(resp.Body), "sk-bad-BBBB") {
|
||||
t.Errorf("response leaks the raw key: %s", string(resp.Body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all keys 401 → 502", func(t *testing.T) {
|
||||
resp, err := HandleManagement(context.Background(), postKeys("sk-bad-CCCC", "sk-bad-DDDD"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("StatusCode = %d, want 502 (all keys upstream-failed), body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var usage OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if usage.OK {
|
||||
t.Errorf("OK = true, want false when all keys fail")
|
||||
}
|
||||
if usage.Error != "" {
|
||||
t.Errorf("top-level error = %q, want empty (per-key errors carry the detail)", usage.Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no keys configured → 400", func(t *testing.T) {
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/management/plugins/commandcode/opencode/usage",
|
||||
}
|
||||
resp, err := HandleManagement(context.Background(), req, &PluginConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("StatusCode = %d, want 400, body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var usage OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(usage.Error, "no opencode api keys configured") {
|
||||
t.Errorf("top-level error = %q, want prefix 'no opencode api keys configured'", usage.Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST body opencode_api_keys overrides config and wins over scalar", func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"opencode_api_key": "sk-scalar-must-lose",
|
||||
"opencode_api_keys": []string{"sk-good-AAAA"},
|
||||
"opencode_api_base": ts.URL,
|
||||
})
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/plugins/commandcode/opencode/usage",
|
||||
Body: body,
|
||||
}
|
||||
cfg := &PluginConfig{OpenCodeAPIKey: "sk-config-must-lose", OpenCodeAPIBase: ts.URL}
|
||||
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var usage OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if len(usage.Keys) != 1 || usage.Keys[0].KeyID != MaskAPIKey("sk-good-AAAA") {
|
||||
t.Errorf("keys = %+v, want only the body-list key (list wins over scalar and config)", usage.Keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Regression: /plugins/commandcode/all must not be swallowed by the generic
|
||||
// "/usage" suffix match nor miss its dedicated handler.
|
||||
func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) {
|
||||
@@ -314,10 +516,14 @@ func TestHandleManagement_AllRoute_BothProvidersOK(t *testing.T) {
|
||||
if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK {
|
||||
t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage)
|
||||
}
|
||||
var ocUsage OpenCodeFormattedUsageResponse
|
||||
// v0.4.0: the opencode field carries the multi-key envelope.
|
||||
var ocUsage OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(all.OpenCode, &ocUsage); err != nil || !ocUsage.OK {
|
||||
t.Errorf("opencode payload invalid: err=%v usage=%+v", err, ocUsage)
|
||||
}
|
||||
if len(ocUsage.Keys) != 1 || !ocUsage.Keys[0].OK || ocUsage.Keys[0].Windows == nil {
|
||||
t.Errorf("opencode keys = %+v, want one successful key with windows", ocUsage.Keys)
|
||||
}
|
||||
}
|
||||
|
||||
// Partial failure: one provider fails upstream → ok stays true, the failed
|
||||
@@ -377,8 +583,10 @@ func TestHandleManagement_AllUsage_PartialFailure(t *testing.T) {
|
||||
if _, present := all.Errors["opencode"]; !present {
|
||||
t.Errorf("expected errors[opencode] to be set, got %v", all.Errors)
|
||||
}
|
||||
if !strings.Contains(all.Errors["opencode"], "opencode upstream returned 500") {
|
||||
t.Errorf("errors[opencode] = %q, want it to mention 'opencode upstream returned 500'", all.Errors["opencode"])
|
||||
// v0.4.0: a configured-but-failed key is an upstream failure; with the
|
||||
// single configured key failing, the aggregate message is "all N keys failed".
|
||||
if !strings.Contains(all.Errors["opencode"], "all 1 opencode keys failed") {
|
||||
t.Errorf("errors[opencode] = %q, want it to mention 'all 1 opencode keys failed'", all.Errors["opencode"])
|
||||
}
|
||||
// opencode field must be omitted (omitempty), not serialized as "null".
|
||||
if strings.Contains(string(resp.Body), `"opencode":null`) {
|
||||
@@ -450,6 +658,154 @@ func TestHandleManagement_AllUsage_Upstream400NotMisclassified(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// /all matrix (v0.4.0): Command Code upstream down + all opencode keys 401
|
||||
// → every failure is upstream → 502, with the aggregate "all N keys failed"
|
||||
// message in errors["opencode"].
|
||||
func TestHandleManagement_AllUsage_CCUpstreamDown_OpenCodeAll401(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/usage":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
|
||||
default: // commandcode internal endpoints
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"cc exploded"}`))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
cfg := &PluginConfig{
|
||||
SessionToken: "configured-token",
|
||||
APIBase: ts.URL,
|
||||
OpenCodeAPIKeys: []string{"sk-bad-AAAA", "sk-bad-BBBB"},
|
||||
OpenCodeAPIBase: ts.URL,
|
||||
}
|
||||
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/management/plugins/commandcode/all",
|
||||
}
|
||||
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("StatusCode = %d, want 502 (all failures upstream), body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
|
||||
var all AllUsageResponse
|
||||
if err := json.Unmarshal(resp.Body, &all); err != nil {
|
||||
t.Fatalf("unmarshal body error: %v", err)
|
||||
}
|
||||
if all.OK {
|
||||
t.Error("expected ok=false")
|
||||
}
|
||||
if !strings.Contains(all.Errors["opencode"], "all 2 opencode keys failed") {
|
||||
t.Errorf("errors[opencode] = %q, want 'all 2 opencode keys failed'", all.Errors["opencode"])
|
||||
}
|
||||
}
|
||||
|
||||
// /all matrix: Command Code upstream down + one opencode key succeeds → 200
|
||||
// (partial failure); the multi-key opencode payload is inlined.
|
||||
func TestHandleManagement_AllUsage_CCUpstreamDown_OpenCodeOneOK(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/usage" && r.Header.Get("Authorization") == "Bearer sk-good-AAAA":
|
||||
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
|
||||
case r.URL.Path == "/usage":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
|
||||
default: // commandcode internal endpoints
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"cc exploded"}`))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
cfg := &PluginConfig{
|
||||
SessionToken: "configured-token",
|
||||
APIBase: ts.URL,
|
||||
OpenCodeAPIKeys: []string{"sk-good-AAAA", "sk-bad-BBBB"},
|
||||
OpenCodeAPIBase: ts.URL,
|
||||
}
|
||||
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/management/plugins/commandcode/all",
|
||||
}
|
||||
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("StatusCode = %d, want 200 (opencode partial success), body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
|
||||
var all AllUsageResponse
|
||||
if err := json.Unmarshal(resp.Body, &all); err != nil {
|
||||
t.Fatalf("unmarshal body error: %v", err)
|
||||
}
|
||||
if !all.OK {
|
||||
t.Error("expected ok=true (>=1 provider succeeded)")
|
||||
}
|
||||
if _, present := all.Errors["commandcode"]; !present {
|
||||
t.Errorf("expected errors[commandcode], got %v", all.Errors)
|
||||
}
|
||||
if _, present := all.Errors["opencode"]; present {
|
||||
t.Errorf("errors[opencode] must be absent on partial success, got %q", all.Errors["opencode"])
|
||||
}
|
||||
var oc OpenCodeMultiKeyResponse
|
||||
if err := json.Unmarshal(all.OpenCode, &oc); err != nil || !oc.OK {
|
||||
t.Fatalf("opencode payload invalid: err=%v oc=%+v", err, oc)
|
||||
}
|
||||
if len(oc.Keys) != 2 || !oc.Keys[0].OK || oc.Keys[1].OK {
|
||||
t.Errorf("opencode keys = %+v, want [ok, failed]", oc.Keys)
|
||||
}
|
||||
if strings.Contains(string(resp.Body), "sk-good-AAAA") || strings.Contains(string(resp.Body), "sk-bad-BBBB") {
|
||||
t.Errorf("/all response leaks a raw opencode key: %s", string(resp.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalCredentialError(t *testing.T) {
|
||||
local := []string{
|
||||
"session_token is required. Configure ...",
|
||||
"opencode_api_key is required. Configure ...",
|
||||
// v0.4.0 prefix: zero opencode keys configured is a local problem.
|
||||
"no opencode api keys configured. Configure opencode_api_keys (YAML list) ...",
|
||||
}
|
||||
for _, msg := range local {
|
||||
if !isLocalCredentialError(msg) {
|
||||
t.Errorf("isLocalCredentialError(%q) = false, want true", msg)
|
||||
}
|
||||
}
|
||||
|
||||
upstream := []string{
|
||||
"opencode upstream returned 401: check opencode_api_key",
|
||||
"opencode upstream request failed: dial tcp: connection refused",
|
||||
"all 2 opencode keys failed",
|
||||
"upstream returned non-200 status",
|
||||
"failed to parse opencode upstream usage: unexpected end of JSON input",
|
||||
"",
|
||||
}
|
||||
for _, msg := range upstream {
|
||||
if isLocalCredentialError(msg) {
|
||||
t.Errorf("isLocalCredentialError(%q) = true, want false", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown path after the new routes still 404s.
|
||||
func TestHandleManagement_UnknownPath(t *testing.T) {
|
||||
req := ManagementRequest{
|
||||
@@ -464,3 +820,347 @@ func TestHandleManagement_UnknownPath(t *testing.T) {
|
||||
t.Fatalf("StatusCode = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- v0.5.0: commandcode_api_key → /alpha + Bearer path ----
|
||||
|
||||
// mockAlphaCreditsJSON omits opensourceMonthlyCredits, matching the real
|
||||
// /alpha/billing/credits payload shape (field difference vs /internal).
|
||||
const mockAlphaCreditsJSON = `{"credits":{"monthlyCredits":555},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`
|
||||
|
||||
// newAlphaTestServer: /alpha/billing/credits and /alpha/usage/summary both
|
||||
// assert Bearer auth and the absence of a Cookie header; every other path
|
||||
// fails the test (regression guard against falling back to /internal).
|
||||
func newAlphaTestServer(t *testing.T, wantKey string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+wantKey {
|
||||
t.Errorf("upstream Authorization = %q, want Bearer %s", got, wantKey)
|
||||
}
|
||||
if got := r.Header.Get("Cookie"); got != "" {
|
||||
t.Errorf("upstream Cookie = %q, want none on the /alpha path", got)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/alpha/billing/credits":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(mockAlphaCreditsJSON))
|
||||
case "/alpha/usage/summary":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s (internal path must not be hit when commandcode_api_key is set)", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// GET /usage with commandcode_api_key configured → both upstream calls hit
|
||||
// /alpha/* with Bearer auth and no Cookie; response parses with the alpha
|
||||
// payload (total = monthly when opensource field is absent) and the monthly
|
||||
// window is derived from the /alpha summary.
|
||||
func TestHandleManagement_GetUsage_AlphaKey(t *testing.T) {
|
||||
ts := newAlphaTestServer(t, "user_cfg-key")
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
cfg := &PluginConfig{
|
||||
CommandCodeAPIKey: "user_cfg-key",
|
||||
APIBase: ts.URL,
|
||||
}
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/management/plugins/commandcode/usage",
|
||||
}
|
||||
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 FormattedUsageResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil {
|
||||
t.Fatalf("unmarshal body error: %v", err)
|
||||
}
|
||||
if !usage.OK {
|
||||
t.Fatal("expected OK=true")
|
||||
}
|
||||
if usage.Credits.MonthlyCredits != 555 || usage.Credits.TotalCredits != 555 {
|
||||
t.Errorf("credits = %+v, want monthly=555 total=555 (opensource absent in alpha payload)", usage.Credits)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /usage: body commandcode_api_key overrides the configured key (the
|
||||
// config key gets a 401 from the mock, so a 200 proves the body key won).
|
||||
func TestHandleManagement_PostUsage_AlphaKeyBodyOverridesConfig(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("Authorization") {
|
||||
case "Bearer user_body-key":
|
||||
switch r.URL.Path {
|
||||
case "/alpha/billing/credits":
|
||||
_, _ = w.Write([]byte(mockAlphaCreditsJSON))
|
||||
case "/alpha/usage/summary":
|
||||
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 40}`))
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
default:
|
||||
t.Errorf("upstream got Authorization %q — config key must lose to the POST body key", r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"commandcode_api_key": "user_body-key",
|
||||
"api_base": ts.URL,
|
||||
})
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/plugins/commandcode/usage",
|
||||
Body: reqBody,
|
||||
}
|
||||
cfg := &PluginConfig{CommandCodeAPIKey: "user_cfg-must-lose", APIBase: 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 key overrides config), body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var usage FormattedUsageResponse
|
||||
if err := json.Unmarshal(resp.Body, &usage); err != nil || !usage.OK {
|
||||
t.Errorf("unexpected response: err=%v usage=%+v", err, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: without commandcode_api_key the legacy /internal + Cookie path
|
||||
// is preserved; the /alpha endpoints must never be requested.
|
||||
func TestHandleManagement_Usage_FallbackToInternalCookie(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/alpha/") {
|
||||
t.Errorf("unexpected /alpha request %s — must stay on /internal without commandcode_api_key", r.URL.Path)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/internal/billing/credits":
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "legacy-cookie-token") {
|
||||
t.Errorf("Cookie = %q, want the session token cookie", r.Header.Get("Cookie"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 321},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
|
||||
case "/internal/usage/summary":
|
||||
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 11}`))
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", 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: "legacy-cookie-token", APIBase: ts.URL}
|
||||
|
||||
t.Run("GET falls back to internal", func(t *testing.T) {
|
||||
req := ManagementRequest{Method: http.MethodGet, Path: "/plugins/commandcode/usage"}
|
||||
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))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST falls back to internal", func(t *testing.T) {
|
||||
reqBody, _ := json.Marshal(map[string]string{"session_token": "legacy-cookie-token", "api_base": ts.URL})
|
||||
req := ManagementRequest{Method: http.MethodPost, Path: "/plugins/commandcode/usage", Body: reqBody}
|
||||
resp, err := HandleManagement(context.Background(), req, &PluginConfig{})
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// GET must NOT honor a commandcode_api_key query parameter (same
|
||||
// secrets-out-of-URLs policy as the OpenCode handler): the config's
|
||||
// session_token path is used and the query-provided key is ignored.
|
||||
func TestHandleManagement_GetUsage_NoQueryKeyOverride(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/alpha/billing/credits" || r.URL.Path == "/alpha/usage/summary" {
|
||||
t.Errorf("unexpected /alpha request %s — query override of commandcode_api_key is not supported", r.URL.Path)
|
||||
}
|
||||
if r.URL.Path != "/internal/billing/credits" {
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Cookie") == "" {
|
||||
t.Errorf("Cookie = %q, want the configured session token cookie", r.Header.Get("Cookie"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits": 77},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
cfg := &PluginConfig{SessionToken: "configured-cookie-token", APIBase: ts.URL}
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/management/plugins/commandcode/usage",
|
||||
Query: map[string][]string{
|
||||
"commandcode_api_key": {"user_query-must-be-ignored"},
|
||||
},
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// Alpha upstream non-200 → statusCode passed through, message points at
|
||||
// commandcode_api_key, and the upstream body is NOT echoed (unlike the
|
||||
// internal path).
|
||||
func TestHandleManagement_Usage_AlphaUpstreamNon200(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"upstream secret detail xyzzy"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
cfg := &PluginConfig{CommandCodeAPIKey: "user_bad-key", APIBase: ts.URL}
|
||||
resp, err := HandleManagement(context.Background(), ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/plugins/commandcode/usage",
|
||||
}, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleManagement error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("StatusCode = %d, want 401 (upstream status passed through)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var errResp struct {
|
||||
OK bool `json:"ok"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Error string `json:"error"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &errResp); err != nil {
|
||||
t.Fatalf("unmarshal error body: %v", err)
|
||||
}
|
||||
if errResp.OK || errResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("error payload = %+v, want ok=false status_code=401", errResp)
|
||||
}
|
||||
if !strings.Contains(errResp.Error, "commandcode upstream returned 401: check commandcode_api_key") {
|
||||
t.Errorf("error = %q, want the commandcode_api_key hint message", errResp.Error)
|
||||
}
|
||||
if strings.Contains(string(resp.Body), "upstream secret detail") {
|
||||
t.Errorf("alpha branch must not echo the upstream body, got: %s", string(resp.Body))
|
||||
}
|
||||
}
|
||||
|
||||
// Neither credential → 400 with the preserved "session_token is required"
|
||||
// prefix (isLocalCredentialError in /all depends on it).
|
||||
func TestHandleManagement_Usage_NoCredentialsStillSessionTokenMessage(t *testing.T) {
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
resp, err := HandleManagement(context.Background(), ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/plugins/commandcode/usage",
|
||||
}, &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))
|
||||
}
|
||||
if msg := extractErrorResponseMessage(resp.Body); !strings.HasPrefix(msg, "session_token is required") {
|
||||
t.Errorf("error = %q, want the preserved 'session_token is required' prefix", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// /all: POST body commandcode_api_key overrides the configured session_token
|
||||
// (and config key) — the Command Code provider goes through /alpha + Bearer.
|
||||
func TestHandleManagement_AllUsage_CommandCodeAPIKeyOverride(t *testing.T) {
|
||||
ts := newAlphaTestServer(t, "user_all-key")
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
cfg := &PluginConfig{
|
||||
SessionToken: "cfg-token-must-lose",
|
||||
APIBase: ts.URL,
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]string{"commandcode_api_key": "user_all-key"})
|
||||
req := ManagementRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/plugins/commandcode/all",
|
||||
Body: reqBody,
|
||||
}
|
||||
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 (commandcode succeeded via /alpha), body=%s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
|
||||
var all AllUsageResponse
|
||||
if err := json.Unmarshal(resp.Body, &all); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if !all.OK {
|
||||
t.Error("expected ok=true")
|
||||
}
|
||||
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)
|
||||
}
|
||||
if ccUsage.Credits.TotalCredits != 555 {
|
||||
t.Errorf("commandcode total_credits = %v, want 555 (alpha payload)", ccUsage.Credits.TotalCredits)
|
||||
}
|
||||
// OpenCode key missing → local-credential error for that provider only.
|
||||
if _, present := all.Errors["opencode"]; !present {
|
||||
t.Errorf("expected errors[opencode], got %v", all.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
+75
-3
@@ -13,7 +13,7 @@ import (
|
||||
const (
|
||||
PluginID = "commandcode"
|
||||
PluginName = "commandcode"
|
||||
PluginVersion = "0.3.0"
|
||||
PluginVersion = "0.5.0"
|
||||
PluginAuthor = "zgs225"
|
||||
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
||||
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
||||
@@ -24,8 +24,10 @@ const (
|
||||
type PluginConfig struct {
|
||||
mu sync.RWMutex
|
||||
SessionToken string `yaml:"session_token" json:"session_token"`
|
||||
CommandCodeAPIKey string `yaml:"commandcode_api_key" json:"commandcode_api_key"`
|
||||
APIBase string `yaml:"api_base" json:"api_base"`
|
||||
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
|
||||
OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"`
|
||||
OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"`
|
||||
}
|
||||
|
||||
@@ -36,8 +38,10 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
||||
}
|
||||
var tmp struct {
|
||||
SessionToken string `yaml:"session_token"`
|
||||
CommandCodeAPIKey string `yaml:"commandcode_api_key"`
|
||||
APIBase string `yaml:"api_base"`
|
||||
OpenCodeAPIKey string `yaml:"opencode_api_key"`
|
||||
OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
|
||||
OpenCodeAPIBase string `yaml:"opencode_api_base"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &tmp); err != nil {
|
||||
@@ -50,6 +54,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
||||
if tmp.SessionToken != "" {
|
||||
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
|
||||
}
|
||||
if tmp.CommandCodeAPIKey != "" {
|
||||
// Provider API key is a plain Bearer token; do not run it through
|
||||
// ExtractSessionToken (that is Command Code cookie specific).
|
||||
c.CommandCodeAPIKey = strings.TrimSpace(tmp.CommandCodeAPIKey)
|
||||
}
|
||||
if tmp.APIBase != "" {
|
||||
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
|
||||
}
|
||||
@@ -58,6 +67,15 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
||||
// ExtractSessionToken (that is Command Code cookie specific).
|
||||
c.OpenCodeAPIKey = strings.TrimSpace(tmp.OpenCodeAPIKey)
|
||||
}
|
||||
// Merge rule: opencode_api_keys (YAML list) wins when non-empty after
|
||||
// trimming/dedup; otherwise opencode_api_key (scalar) degrades to a
|
||||
// single-key list; both empty means no keys.
|
||||
c.OpenCodeAPIKeys = normalizeOpenCodeKeys(tmp.OpenCodeAPIKeys)
|
||||
if len(c.OpenCodeAPIKeys) == 0 {
|
||||
if single := strings.TrimSpace(tmp.OpenCodeAPIKey); single != "" {
|
||||
c.OpenCodeAPIKeys = []string{single}
|
||||
}
|
||||
}
|
||||
if tmp.OpenCodeAPIBase != "" {
|
||||
c.OpenCodeAPIBase = strings.TrimRight(tmp.OpenCodeAPIBase, "/")
|
||||
}
|
||||
@@ -74,6 +92,15 @@ func (c *PluginConfig) GetSessionToken() string {
|
||||
return c.SessionToken
|
||||
}
|
||||
|
||||
// GetCommandCodeAPIKey safely returns the configured Command Code Provider
|
||||
// API key. When non-empty, usage queries go through the /alpha endpoints
|
||||
// with Bearer auth instead of the session-cookie /internal endpoints.
|
||||
func (c *PluginConfig) GetCommandCodeAPIKey() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.CommandCodeAPIKey
|
||||
}
|
||||
|
||||
// SetSessionToken safely sets the session token.
|
||||
func (c *PluginConfig) SetSessionToken(token string) {
|
||||
c.mu.Lock()
|
||||
@@ -91,13 +118,48 @@ func (c *PluginConfig) GetAPIBase() string {
|
||||
return c.APIBase
|
||||
}
|
||||
|
||||
// GetOpenCodeAPIKey safely returns the OpenCode Go API key.
|
||||
// GetOpenCodeAPIKey safely returns the single configured OpenCode Go API key
|
||||
// (scalar opencode_api_key field; kept for backward compatibility).
|
||||
func (c *PluginConfig) GetOpenCodeAPIKey() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.OpenCodeAPIKey
|
||||
}
|
||||
|
||||
// GetOpenCodeAPIKeys safely returns the configured OpenCode Go API keys.
|
||||
// The list field wins; when it is empty the scalar OpenCodeAPIKey degrades
|
||||
// to a single-key list (same merge rule as UpdateFromYAML). The returned
|
||||
// slice is a copy; callers may not mutate it.
|
||||
func (c *PluginConfig) GetOpenCodeAPIKeys() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if len(c.OpenCodeAPIKeys) > 0 {
|
||||
out := make([]string, len(c.OpenCodeAPIKeys))
|
||||
copy(out, c.OpenCodeAPIKeys)
|
||||
return out
|
||||
}
|
||||
if c.OpenCodeAPIKey != "" {
|
||||
return []string{c.OpenCodeAPIKey}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeOpenCodeKeys trims each key, drops empties and dedups while
|
||||
// preserving the original order.
|
||||
func normalizeOpenCodeKeys(keys []string) []string {
|
||||
out := make([]string, 0, len(keys))
|
||||
seen := make(map[string]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" || seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetOpenCodeAPIBase safely returns the OpenCode Go API base URL,
|
||||
// falling back to DefaultOpenCodeAPIBase when unset.
|
||||
func (c *PluginConfig) GetOpenCodeAPIBase() string {
|
||||
@@ -173,6 +235,11 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
|
||||
Type: "string",
|
||||
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
|
||||
},
|
||||
{
|
||||
Name: "commandcode_api_key",
|
||||
Type: "string",
|
||||
Description: "Command Code Provider API key (user_…); when set, usage queries go through the /alpha endpoints with Bearer auth — no session cookie needed",
|
||||
},
|
||||
{
|
||||
Name: "api_base",
|
||||
Type: "string",
|
||||
@@ -181,7 +248,12 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
|
||||
{
|
||||
Name: "opencode_api_key",
|
||||
Type: "string",
|
||||
Description: "OpenCode Go API key (Bearer token used for https://opencode.ai/zen/go/v1/usage)",
|
||||
Description: "OpenCode Go API key (single Bearer token; degraded path when opencode_api_keys is unset)",
|
||||
},
|
||||
{
|
||||
Name: "opencode_api_keys",
|
||||
Type: "string",
|
||||
Description: "OpenCode Go API keys as a YAML list (e.g. opencode_api_keys: [\"sk-KEY1\", \"sk-KEY2\"]); takes precedence over opencode_api_key",
|
||||
},
|
||||
{
|
||||
Name: "opencode_api_base",
|
||||
|
||||
+128
-4
@@ -46,15 +46,15 @@ api_base: "https://custom-api.commandcode.ai"
|
||||
t.Errorf("Capabilities.ManagementAPI = false, want true")
|
||||
}
|
||||
|
||||
// Verify config fields
|
||||
if len(reg.Metadata.ConfigFields) != 4 {
|
||||
t.Fatalf("ConfigFields len = %d, want 4", len(reg.Metadata.ConfigFields))
|
||||
// Verify config fields (v0.5.0: 5 → 6, adds commandcode_api_key)
|
||||
if len(reg.Metadata.ConfigFields) != 6 {
|
||||
t.Fatalf("ConfigFields len = %d, want 6", len(reg.Metadata.ConfigFields))
|
||||
}
|
||||
fieldNames := map[string]bool{}
|
||||
for _, f := range reg.Metadata.ConfigFields {
|
||||
fieldNames[f.Name] = true
|
||||
}
|
||||
if !fieldNames["session_token"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_base"] {
|
||||
if !fieldNames["session_token"] || !fieldNames["commandcode_api_key"] || !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)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,44 @@ session_token: "new-token-abc"
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginConfig_CommandCodeAPIKey(t *testing.T) {
|
||||
p := NewPlugin()
|
||||
// Trimmed, and NOT run through ExtractSessionToken (it is a plain
|
||||
// Bearer token, not a Command Code cookie string).
|
||||
configYAML := []byte("commandcode_api_key: \" user_abc123xyz \"\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.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
|
||||
t.Errorf("CommandCodeAPIKey = %q, want user_abc123xyz (trimmed, raw)", got)
|
||||
}
|
||||
|
||||
// Whitespace-only value clears the field.
|
||||
p2 := NewPlugin()
|
||||
spaceReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("commandcode_api_key: \" \"\n")})
|
||||
if _, err := p2.HandleMethod("plugin.register", spaceReq); err != nil {
|
||||
t.Fatalf("handleMethod(plugin.register) error: %v", err)
|
||||
}
|
||||
if got := p2.config.GetCommandCodeAPIKey(); got != "" {
|
||||
t.Errorf("CommandCodeAPIKey = %q, want empty (whitespace-only)", got)
|
||||
}
|
||||
|
||||
// Omitting the key in a reconfigure must not clear a configured value.
|
||||
reconfReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("session_token: \"tok\"\n")})
|
||||
if _, err := p.HandleMethod("plugin.reconfigure", reconfReq); err != nil {
|
||||
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
|
||||
}
|
||||
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
|
||||
t.Errorf("CommandCodeAPIKey after reconfigure = %q, want kept user_abc123xyz", got)
|
||||
}
|
||||
|
||||
// Default is empty.
|
||||
if got := NewPlugin().config.GetCommandCodeAPIKey(); got != "" {
|
||||
t.Errorf("default CommandCodeAPIKey = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAuthIdentifier_NotHandled(t *testing.T) {
|
||||
p := NewPlugin()
|
||||
raw, err := p.HandleMethod("auth.identifier", nil)
|
||||
@@ -169,4 +207,90 @@ func TestPluginConfig_OpenCode(t *testing.T) {
|
||||
if got := empty.config.GetOpenCodeAPIKey(); got != "" {
|
||||
t.Errorf("default OpenCodeAPIKey = %q, want empty", got)
|
||||
}
|
||||
if got := empty.config.GetOpenCodeAPIKeys(); len(got) != 0 {
|
||||
t.Errorf("default GetOpenCodeAPIKeys = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginConfig_OpenCodeAPIKeys(t *testing.T) {
|
||||
newCfg := func(t *testing.T, yaml string) *PluginConfig {
|
||||
t.Helper()
|
||||
cfg := &PluginConfig{}
|
||||
if err := cfg.UpdateFromYAML([]byte(yaml)); err != nil {
|
||||
t.Fatalf("UpdateFromYAML error: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
t.Run("list takes precedence over scalar", func(t *testing.T) {
|
||||
cfg := newCfg(t, `
|
||||
opencode_api_key: "sk-scalar"
|
||||
opencode_api_keys:
|
||||
- " sk-key1 "
|
||||
- "sk-key2"
|
||||
`)
|
||||
got := cfg.GetOpenCodeAPIKeys()
|
||||
if len(got) != 2 || got[0] != "sk-key1" || got[1] != "sk-key2" {
|
||||
t.Errorf("GetOpenCodeAPIKeys = %v, want [sk-key1 sk-key2] (list wins, trimmed)", got)
|
||||
}
|
||||
if cfg.GetOpenCodeAPIKey() != "sk-scalar" {
|
||||
t.Errorf("GetOpenCodeAPIKey = %q, want sk-scalar (scalar field kept)", cfg.GetOpenCodeAPIKey())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scalar degrades to single-key list", func(t *testing.T) {
|
||||
cfg := newCfg(t, `
|
||||
opencode_api_key: " sk-only "
|
||||
`)
|
||||
got := cfg.GetOpenCodeAPIKeys()
|
||||
if len(got) != 1 || got[0] != "sk-only" {
|
||||
t.Errorf("GetOpenCodeAPIKeys = %v, want [sk-only]", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dedup preserve order and drop empties", func(t *testing.T) {
|
||||
cfg := newCfg(t, `
|
||||
opencode_api_keys:
|
||||
- "sk-b"
|
||||
- ""
|
||||
- " "
|
||||
- "sk-a"
|
||||
- "sk-b"
|
||||
- "sk-c"
|
||||
- "sk-a"
|
||||
`)
|
||||
got := cfg.GetOpenCodeAPIKeys()
|
||||
want := []string{"sk-b", "sk-a", "sk-c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("GetOpenCodeAPIKeys = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("GetOpenCodeAPIKeys[%d] = %q, want %q (order preserved, deduped)", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all empty yields no keys", func(t *testing.T) {
|
||||
for _, yaml := range []string{
|
||||
`opencode_api_key: ""`,
|
||||
"opencode_api_keys: []\nopencode_api_key: \" \"",
|
||||
"opencode_api_keys:\n - \"\"\n - \" \"",
|
||||
} {
|
||||
cfg := newCfg(t, yaml)
|
||||
if got := cfg.GetOpenCodeAPIKeys(); len(got) != 0 {
|
||||
t.Errorf("yaml %q: GetOpenCodeAPIKeys = %v, want empty", yaml, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getter returns a copy", func(t *testing.T) {
|
||||
cfg := newCfg(t, "opencode_api_keys:\n - sk-a\n - sk-b\n")
|
||||
got := cfg.GetOpenCodeAPIKeys()
|
||||
got[0] = "mutated"
|
||||
again := cfg.GetOpenCodeAPIKeys()
|
||||
if again[0] != "sk-a" {
|
||||
t.Errorf("GetOpenCodeAPIKeys not a copy: after mutation got %q", again[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+282
-214
@@ -10,7 +10,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>用量配额 - Command Code + OpenCode Go - CLIProxyAPI</title>
|
||||
<title>用量配额 - CLIProxyAPI</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-page: #f8fafc;
|
||||
@@ -217,11 +217,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
}
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -666,12 +661,110 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* OpenCode card status chip (reuses .status-badge variants) */
|
||||
.oc-status-wrap {
|
||||
/* Command Code tab head: title + plan badge (planBadge moved out of header) */
|
||||
.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;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
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 */
|
||||
@@ -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 {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -813,20 +917,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-title">
|
||||
用量配额 - Command Code + OpenCode Go
|
||||
<span class="version-tag">v0.3.0</span>
|
||||
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
|
||||
用量配额
|
||||
<span class="version-tag">v0.5.0</span>
|
||||
</div>
|
||||
<div class="brand-subtitle">CLIProxyAPI 实时限额与用量监控 (Command Code + OpenCode Go)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="配置选项">
|
||||
<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>
|
||||
@@ -847,9 +944,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
|
||||
<!-- 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="all">All</button>
|
||||
</div>
|
||||
|
||||
<!-- Alert Message -->
|
||||
@@ -874,9 +971,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
<input type="password" id="inputSessionToken" class="form-control" placeholder="覆盖测试: __Secure-commandcode_prod_.session_token" />
|
||||
</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>
|
||||
<label for="inputOpenCodeKeys">OpenCode Go API Keys (测试覆盖)</label>
|
||||
<textarea id="inputOpenCodeKeys" class="form-control" rows="3" placeholder="每行一个 sk-..."></textarea>
|
||||
<span class="form-hint">每行一个 key;仅当次请求生效,不持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 14px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
@@ -885,7 +982,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Command Code -->
|
||||
<div id="sectionCommandcode" class="tab-section active">
|
||||
<div id="sectionCommandcode" class="tab-section">
|
||||
<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>
|
||||
@@ -895,6 +992,10 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<div class="metrics-grid">
|
||||
@@ -1035,110 +1136,29 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
</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 id="ocContent"></div>
|
||||
</div>
|
||||
<!-- /Tab: OpenCode Go -->
|
||||
|
||||
<!-- Tab: All -->
|
||||
<div id="sectionAll" class="tab-section">
|
||||
<div class="all-grid">
|
||||
<div id="sectionAll" class="tab-section active">
|
||||
<!-- All tab: vertical groups, one group title per provider -->
|
||||
<div class="all-group-title">Command Code</div>
|
||||
<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-group-title">OpenCode Go</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 -->
|
||||
@@ -1163,11 +1183,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
const settingsDrawer = document.getElementById("settingsDrawer");
|
||||
const inputMgmtKey = document.getElementById("inputMgmtKey");
|
||||
const inputSessionToken = document.getElementById("inputSessionToken");
|
||||
const inputOpenCodeKey = document.getElementById("inputOpenCodeKey");
|
||||
const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys");
|
||||
const alertBox = document.getElementById("alertBox");
|
||||
const alertMsg = document.getElementById("alertMsg");
|
||||
const statusBadge = document.getElementById("statusBadge");
|
||||
const statusText = document.getElementById("statusText");
|
||||
const planBadge = document.getElementById("planBadge");
|
||||
|
||||
// Tab sections and per-provider error cards
|
||||
@@ -1215,32 +1233,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
const timerWeekly = document.getElementById("timerWeekly");
|
||||
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 monthlyTargetTime = null;
|
||||
|
||||
let weeklyTargetTime = null;
|
||||
let timerInterval = null;
|
||||
|
||||
@@ -1249,8 +1244,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
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 };
|
||||
let activeTab = "all";
|
||||
|
||||
function getStoredManagementKey() {
|
||||
if (inputMgmtKey.value.trim()) {
|
||||
@@ -1346,26 +1340,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
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) {
|
||||
@@ -1391,9 +1368,11 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
ocErrorMsg.textContent = msg;
|
||||
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() {
|
||||
if (monthlyTargetTime) {
|
||||
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
|
||||
@@ -1404,10 +1383,20 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
if (weeklyTargetTime) {
|
||||
timerWeekly.textContent = formatCountdown(weeklyTargetTime);
|
||||
}
|
||||
for (const key in ocTargets) {
|
||||
if (ocTargets[key]) {
|
||||
ocTimerEls[key].textContent = formatCountdown(ocTargets[key]);
|
||||
const allResetEls = document.querySelectorAll(".oc-win-reset");
|
||||
for (let i = 0; i < allResetEls.length; i++) {
|
||||
const el = allResetEls[i];
|
||||
let target = null;
|
||||
const at = el.getAttribute("data-reset-at");
|
||||
if (at) {
|
||||
const t = new Date(at);
|
||||
if (!isNaN(t.getTime())) target = t;
|
||||
}
|
||||
if (!target) {
|
||||
const secs = Number(el.getAttribute("data-reset-secs"));
|
||||
if (secs > 0) target = new Date(Date.now() + secs * 1000);
|
||||
}
|
||||
el.textContent = target ? formatCountdown(target) : "-";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1488,7 +1477,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
weeklyTargetTime = null;
|
||||
}
|
||||
|
||||
// Per-provider status; the header badge is aggregated in updateGlobalBadge()
|
||||
// Per-provider status; consumed by All tab provider badges
|
||||
let ccLevel;
|
||||
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
|
||||
ccLevel = "exceeded";
|
||||
@@ -1501,50 +1490,107 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
|
||||
updateTimers();
|
||||
}
|
||||
|
||||
function renderOpencode(data) {
|
||||
providerState.opencode.err = null;
|
||||
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 = "";
|
||||
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 }
|
||||
// All tab 的 OpenCode 组与本 tab 共用同一套逐 key 渲染逻辑
|
||||
const rendered = renderOpenCodeKeyGroups(keys);
|
||||
ocContent.innerHTML = rendered.html;
|
||||
updateTimers();
|
||||
|
||||
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 };
|
||||
// provider 级别 = 全部 key 最差(失败 key 按 exceeded 视觉计入)
|
||||
let worst = "online";
|
||||
let html = "";
|
||||
|
||||
defs.forEach(function (d) {
|
||||
const w = d.w || {};
|
||||
keys.forEach(function (k) {
|
||||
const keyId = esc(k.key_id || "***");
|
||||
if (k.ok && k.windows) {
|
||||
const windows = k.windows;
|
||||
let keyWorst = "online";
|
||||
let rowsHtml = "";
|
||||
|
||||
WIN_DEFS.forEach(function (def) {
|
||||
const w = windows[def.key] || {};
|
||||
const pct = clampPercent(w.percent);
|
||||
const level = levelOf(pct, w.exceeded, w.status);
|
||||
if (rank[level] > rank[keyWorst]) keyWorst = level;
|
||||
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;
|
||||
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显 "-"
|
||||
let resetAt = "";
|
||||
let resetSecs = "";
|
||||
if (w.reset_at) {
|
||||
const t = new Date(w.reset_at);
|
||||
if (!isNaN(t.getTime())) target = t;
|
||||
if (!isNaN(t.getTime())) resetAt = t.toISOString();
|
||||
}
|
||||
if (!target && w.reset_in_seconds > 0) {
|
||||
target = new Date(Date.now() + Number(w.reset_in_seconds) * 1000);
|
||||
if (!resetAt && w.reset_in_seconds > 0) {
|
||||
resetSecs = String(w.reset_in_seconds);
|
||||
}
|
||||
ocTargets[d.key] = target;
|
||||
if (!target) d.timer.textContent = "-";
|
||||
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>';
|
||||
});
|
||||
|
||||
setProviderStatus("opencode", worst);
|
||||
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>';
|
||||
}
|
||||
});
|
||||
|
||||
return { html: html, worst: worst };
|
||||
}
|
||||
|
||||
function renderAllTab() {
|
||||
@@ -1581,12 +1627,43 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
});
|
||||
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(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 =
|
||||
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);
|
||||
'<div class="oc-key-body">' + ccRows + '</div>';
|
||||
} else {
|
||||
allBadgeCommandcode.className = "status-badge";
|
||||
allBadgeTextCommandcode.textContent = "尚未加载";
|
||||
@@ -1600,19 +1677,10 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>';
|
||||
} 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);
|
||||
});
|
||||
// 多 key 契约:逐 key 完整卡片,与 OpenCode tab 共用同一渲染函数
|
||||
const keys = Array.isArray(data.keys) ? data.keys : [];
|
||||
const rendered = renderOpenCodeKeyGroups(keys);
|
||||
const html = rendered.html || '<div class="all-summary-value">无 key 数据</div>';
|
||||
allBadgeOpencode.className = "status-badge " + badgeVisualClass(oc.level);
|
||||
allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-";
|
||||
allBodyOpencode.innerHTML = html;
|
||||
@@ -1632,9 +1700,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
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") {
|
||||
if (tab === "all") {
|
||||
// All 为默认 tab:激活 All 时清掉 hash(刷新回到默认)
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
} else {
|
||||
location.hash = tab;
|
||||
@@ -1648,7 +1716,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
|
||||
const mgmtKey = getStoredManagementKey();
|
||||
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 = {
|
||||
"Accept": "application/json",
|
||||
@@ -1665,8 +1733,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
if (overrideToken) {
|
||||
bodyObj.session_token = overrideToken;
|
||||
}
|
||||
if (overrideOpenCodeKey) {
|
||||
bodyObj.opencode_api_key = overrideOpenCodeKey;
|
||||
// 非空才进 body:list 优先(后端仍接受旧 scalar 字段,前端不再发送)
|
||||
if (overrideOpenCodeKeys.length > 0) {
|
||||
bodyObj.opencode_api_keys = overrideOpenCodeKeys;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1679,8 +1748,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
settingsDrawer.classList.add("open");
|
||||
showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true);
|
||||
statusBadge.className = "status-badge warning";
|
||||
statusText.textContent = "未授权";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1833,11 +1900,12 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
}
|
||||
|
||||
// Restore tab from location.hash, then initial fetch
|
||||
// 无 hash 默认 All:All tab 才能通过刷新后的 #all hash 恢复
|
||||
const initHash = location.hash.replace(/^#/, "");
|
||||
if (initHash === "opencode" || initHash === "all") {
|
||||
if (initHash === "opencode" || initHash === "commandcode") {
|
||||
setActiveTab(initHash, false);
|
||||
} else {
|
||||
setActiveTab("commandcode", false);
|
||||
setActiveTab("all", false);
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
|
||||
+27
-2
@@ -294,10 +294,35 @@ type OpenCodeFormattedUsageResponse struct {
|
||||
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.
|
||||
// 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.
|
||||
// failures are reported in Errors. CommandCode carries the raw JSON of
|
||||
// FormattedUsageResponse; OpenCode carries the raw JSON of
|
||||
// OpenCodeMultiKeyResponse (v0.4.0 breaking change: no longer the single-key
|
||||
// OpenCodeFormattedUsageResponse).
|
||||
type AllUsageResponse struct {
|
||||
OK bool `json:"ok"` // at least one provider succeeded
|
||||
CommandCode json.RawMessage `json:"commandcode,omitempty"`
|
||||
|
||||
@@ -142,6 +142,41 @@ func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hos
|
||||
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
|
||||
}
|
||||
|
||||
// fetchUpstreamAlpha performs a GET on a Command Code /alpha endpoint using a
|
||||
// Provider API key (Bearer auth) instead of a session cookie, reusing the
|
||||
// host.http.do bridge when available, else falling back to net/http.
|
||||
func fetchUpstreamAlpha(ctx context.Context, apiBase, endpoint, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return nil, http.StatusBadRequest, errors.New("missing commandcode_api_key: please provide a valid Command Code Provider API key")
|
||||
}
|
||||
|
||||
if apiBase == "" {
|
||||
apiBase = DefaultAPIBase
|
||||
}
|
||||
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// FetchCommandCodeCreditsAlphaRaw fetches raw credit data from
|
||||
// {apiBase}/alpha/billing/credits with Bearer auth (Provider API key),
|
||||
// via host.http.do or net/http fallback.
|
||||
func FetchCommandCodeCreditsAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||
return fetchUpstreamAlpha(ctx, apiBase, "alpha/billing/credits", apiKey, hostCallbackID)
|
||||
}
|
||||
|
||||
// FetchCommandCodeUsageSummaryAlphaRaw fetches the billing-period (monthly)
|
||||
// usage totals from {apiBase}/alpha/usage/summary with Bearer auth.
|
||||
func FetchCommandCodeUsageSummaryAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||
return fetchUpstreamAlpha(ctx, apiBase, "alpha/usage/summary", apiKey, 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) {
|
||||
@@ -224,6 +259,31 @@ func clampOpenCodePercent(p float64) float64 {
|
||||
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.
|
||||
// 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) {
|
||||
|
||||
@@ -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) {
|
||||
mockResponsePayload := []byte(`{"usage":{"rolling":{"status":"ok","percent":7,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
|
||||
|
||||
@@ -686,3 +790,128 @@ func TestFetchOpenCodeUsageRaw_HostCaller(t *testing.T) {
|
||||
t.Errorf("host request Authorization = %v, want Bearer sk-host-key", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// v0.5.0: the /alpha endpoints authenticate with a Bearer Provider API key
|
||||
// instead of the session cookie. The URL must be /alpha/billing/credits and
|
||||
// no Cookie header may be sent.
|
||||
func TestFetchCommandCodeCreditsAlphaRaw_FallbackHTTP(t *testing.T) {
|
||||
var sawAuth, sawCookie, sawAccept, sawUA string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/alpha/billing/credits" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sawAuth = r.Header.Get("Authorization")
|
||||
sawCookie = r.Header.Get("Cookie")
|
||||
sawAccept = r.Header.Get("Accept")
|
||||
sawUA = r.Header.Get("User-Agent")
|
||||
|
||||
// Alpha credits omit opensourceMonthlyCredits (field difference vs
|
||||
// the internal endpoint); formatCredits must tolerate that.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":700},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
body, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), ts.URL, "user_test-key", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchCommandCodeCreditsAlphaRaw error: %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", status)
|
||||
}
|
||||
if sawAuth != "Bearer user_test-key" {
|
||||
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
|
||||
}
|
||||
if sawCookie != "" {
|
||||
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
|
||||
}
|
||||
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 := ParseAndFormatUsage(body, nil, time.Time{})
|
||||
if errParse != nil {
|
||||
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
|
||||
}
|
||||
if usage.Credits.MonthlyCredits != 700 {
|
||||
t.Errorf("MonthlyCredits = %v, want 700", usage.Credits.MonthlyCredits)
|
||||
}
|
||||
if usage.Credits.OpensourceMonthlyCredits != 0 {
|
||||
t.Errorf("OpensourceMonthlyCredits = %v, want 0 (field absent in alpha payload)", usage.Credits.OpensourceMonthlyCredits)
|
||||
}
|
||||
// total = monthly + 0 when opensourceMonthlyCredits is missing.
|
||||
if usage.Credits.TotalCredits != 700 {
|
||||
t.Errorf("TotalCredits = %v, want 700 (= monthly when opensource field absent)", usage.Credits.TotalCredits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCommandCodeUsageSummaryAlphaRaw_FallbackHTTP(t *testing.T) {
|
||||
var sawAuth, sawCookie string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/alpha/usage/summary" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sawAuth = r.Header.Get("Authorization")
|
||||
sawCookie = r.Header.Get("Cookie")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 123}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
SetHostCaller(nil)
|
||||
SetDefaultHTTPClient(ts.Client())
|
||||
defer func() {
|
||||
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||
}()
|
||||
|
||||
body, status, err := FetchCommandCodeUsageSummaryAlphaRaw(context.Background(), ts.URL+"/", "user_test-key", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchCommandCodeUsageSummaryAlphaRaw error: %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", status)
|
||||
}
|
||||
if sawAuth != "Bearer user_test-key" {
|
||||
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
|
||||
}
|
||||
if sawCookie != "" {
|
||||
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
|
||||
}
|
||||
|
||||
var summary UpstreamUsageSummaryResponse
|
||||
if err := json.Unmarshal(body, &summary); err != nil {
|
||||
t.Fatalf("unmarshal summary error: %v", err)
|
||||
}
|
||||
if summary.TotalMonthlyCredits != 123 {
|
||||
t.Errorf("TotalMonthlyCredits = %v, want 123", summary.TotalMonthlyCredits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCommandCodeCreditsAlphaRaw_MissingKey(t *testing.T) {
|
||||
SetHostCaller(nil)
|
||||
for _, key := range []string{"", " "} {
|
||||
_, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), "", key, "")
|
||||
if err == nil {
|
||||
t.Fatalf("key %q: expected error for missing key", key)
|
||||
}
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("key %q: status = %d, want 400", key, status)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing commandcode_api_key") {
|
||||
t.Errorf("key %q: error = %q, want it to mention missing commandcode_api_key", key, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// pagecheck: syntax-check the embedded quota page JS (guards against
|
||||
// parse-time SyntaxErrors like duplicate const that break the whole page).
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync("plugin/quota_page.go", "utf8");
|
||||
const m = src.match(/const QuotaPageHTML = `([\s\S]*)`/);
|
||||
if (!m) {
|
||||
console.error("pagecheck: QuotaPageHTML not found");
|
||||
process.exit(1);
|
||||
}
|
||||
const js = [...m[1].matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
||||
.map((x) => x[1])
|
||||
.join("\n");
|
||||
if (!js.trim()) {
|
||||
console.error("pagecheck: no <script> content found");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
new Function(js);
|
||||
} catch (e) {
|
||||
console.error("pagecheck: embedded JS SyntaxError:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("pagecheck: embedded JS syntax OK");
|
||||
|
||||
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
|
||||
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
|
||||
// updateTimers() that was never declared and only existed as a global
|
||||
// property accidentally created by renderUsage()).
|
||||
//
|
||||
// Approach (deliberately simple/grep-style, no DOM execution):
|
||||
// 1. strip comments and string literals
|
||||
// 2. collect every var/let/const/function declaration name + function/
|
||||
// callback/catch parameter
|
||||
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
|
||||
// bare if()/while() condition identifiers, and for-loop init identifiers
|
||||
// 4. whitelist known globals; anything left is reported and fails the check
|
||||
|
||||
const whitelist = new Set([
|
||||
// browser builtins referenced by the page
|
||||
"document", "window", "localStorage", "sessionStorage", "fetch",
|
||||
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
|
||||
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
|
||||
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
|
||||
"escape", "unescape", "navigator", "location", "history", "URL",
|
||||
"URLSearchParams", "FormData", "Headers", "Request", "Response",
|
||||
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
|
||||
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
|
||||
]);
|
||||
|
||||
function stripLiterals(source) {
|
||||
// Remove comments, string literals, and regex literals; replace with
|
||||
// harmless placeholders so identifier scanning never sees string content.
|
||||
// Template literals are NOT fully discarded: their ${...} interpolations
|
||||
// are kept as "( ... )" so identifiers inside them stay visible.
|
||||
//
|
||||
// A "/" only starts a regex literal when it appears in expression
|
||||
// position (previous significant token is an operator/open bracket or a
|
||||
// keyword such as return/typeof). Otherwise it is division.
|
||||
let out = "";
|
||||
let i = 0;
|
||||
const n = source.length;
|
||||
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
|
||||
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
|
||||
const stack = [{ type: "expr", depth: Infinity }];
|
||||
// Last significant (non-whitespace) emitted char + last identifier word,
|
||||
// used for the regex-vs-division heuristic.
|
||||
let lastSig = "";
|
||||
let lastWord = "";
|
||||
const KEYWORDS_BEFORE_REGEX = new Set([
|
||||
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
|
||||
"do", "else", "case", "throw", "await", "yield",
|
||||
]);
|
||||
const emit = (text) => {
|
||||
for (const ch of text) {
|
||||
if (/\s/.test(ch)) continue;
|
||||
if (/[A-Za-z0-9_$]/.test(ch)) {
|
||||
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
|
||||
} else {
|
||||
lastWord = "";
|
||||
}
|
||||
lastSig = ch;
|
||||
}
|
||||
out += text;
|
||||
};
|
||||
const regexAllowed = () =>
|
||||
lastSig === "" ||
|
||||
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
|
||||
KEYWORDS_BEFORE_REGEX.has(lastWord);
|
||||
|
||||
while (i < n) {
|
||||
const ctx = stack[stack.length - 1];
|
||||
const c = source[i];
|
||||
const next = source[i + 1];
|
||||
|
||||
if (ctx.type === "tmpl") {
|
||||
// Inside template literal text: skip until ` or ${ ... }
|
||||
if (c === "\\") { i += 2; continue; }
|
||||
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
|
||||
if (c === "$" && next === "{") {
|
||||
i += 2;
|
||||
stack.push({ type: "expr", depth: 0 });
|
||||
emit(" ( ");
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// code context (top-level or template ${...} expression)
|
||||
if (c === "/" && next === "/") {
|
||||
while (i < n && source[i] !== "\n") i++;
|
||||
} else if (c === "/" && next === "*") {
|
||||
i += 2;
|
||||
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
||||
i += 2;
|
||||
} else if (c === "/" && regexAllowed()) {
|
||||
// regex literal: skip to unescaped closing / (not inside [...])
|
||||
i++;
|
||||
let inClass = false;
|
||||
while (i < n) {
|
||||
if (source[i] === "\\") { i += 2; continue; }
|
||||
if (source[i] === "[") { inClass = true; i++; continue; }
|
||||
if (source[i] === "]") { inClass = false; i++; continue; }
|
||||
if (source[i] === "/" && !inClass) { i++; break; }
|
||||
if (source[i] === "\n") break; // malformed; bail out safely
|
||||
i++;
|
||||
}
|
||||
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
|
||||
emit(" / ");
|
||||
} else if (c === '"' || c === "'") {
|
||||
const quote = c;
|
||||
i++;
|
||||
while (i < n) {
|
||||
if (source[i] === "\\") { i += 2; continue; }
|
||||
if (source[i] === quote) { i++; break; }
|
||||
if (source[i] === "\n") break;
|
||||
i++;
|
||||
}
|
||||
emit(" " + quote + quote + " ");
|
||||
} else if (c === "`") {
|
||||
i++;
|
||||
stack.push({ type: "tmpl" });
|
||||
emit(" ");
|
||||
} else {
|
||||
if (c === "{") ctx.depth++;
|
||||
if (c === "}") {
|
||||
if (ctx.depth === 0) {
|
||||
// closes a template interpolation: back into template text
|
||||
stack.pop();
|
||||
i++;
|
||||
emit(" ) ");
|
||||
continue;
|
||||
}
|
||||
ctx.depth--;
|
||||
}
|
||||
emit(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectDeclarations(clean) {
|
||||
const declared = new Set();
|
||||
const addParamList = (raw) => {
|
||||
for (const p of raw.split(",")) {
|
||||
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
|
||||
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
|
||||
}
|
||||
};
|
||||
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
|
||||
declared.add(m[1]);
|
||||
// function declarations/expressions: name + params
|
||||
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
|
||||
if (m[1]) declared.add(m[1]);
|
||||
addParamList(m[2]);
|
||||
}
|
||||
// arrow functions: (a, b) => and a =>
|
||||
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
|
||||
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
|
||||
// catch (e) and destructuring catch
|
||||
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
|
||||
return declared;
|
||||
}
|
||||
|
||||
const clean = stripLiterals(js);
|
||||
const declared = collectDeclarations(clean);
|
||||
const flagged = new Set();
|
||||
|
||||
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
|
||||
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
|
||||
const name = m[1];
|
||||
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||
}
|
||||
// 2. bare if()/while() condition identifiers
|
||||
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
|
||||
const name = m[2];
|
||||
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||
}
|
||||
// 3. for-loop init without let/var: for (i = 0; ...)
|
||||
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
|
||||
const name = m[1];
|
||||
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||
}
|
||||
|
||||
if (flagged.size > 0) {
|
||||
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
|
||||
for (const name of [...flagged].sort()) console.error(" - " + name);
|
||||
console.error(
|
||||
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
|
||||
"if this is a false positive, extend scripts/pagecheck.js"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
|
||||
);
|
||||
Reference in New Issue
Block a user