mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 03:32:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fdc9ad6c8 | ||
|
|
2f1b2fb821 | ||
|
|
3097d96a8e | ||
|
|
0a9213ec38 | ||
|
|
553226c96e | ||
|
|
61c50280b1 | ||
|
|
43c09885e5 |
@@ -15,3 +15,11 @@ coverage.txt
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Release artifacts (attached to GitHub Releases, not tracked in git)
|
||||||
|
*.zip
|
||||||
|
checksums.txt
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Internal planning docs (not for public repo)
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -7,15 +7,22 @@ else
|
|||||||
TARGET := commandcode.so
|
TARGET := commandcode.so
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: all build test clean lint
|
.PHONY: all build test clean lint pagecheck
|
||||||
|
|
||||||
all: build
|
all: build pagecheck
|
||||||
|
|
||||||
build:
|
build:
|
||||||
CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go
|
CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go
|
||||||
|
|
||||||
|
# Extract embedded JS from quota_page.go and syntax-check it with node.
|
||||||
|
# Guards against parse-time SyntaxErrors (e.g. duplicate const) that break
|
||||||
|
# the whole resource page; Go substring tests cannot catch these.
|
||||||
|
pagecheck:
|
||||||
|
@node scripts/pagecheck.js
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -race ./...
|
go test -v -race ./...
|
||||||
|
$(MAKE) pagecheck
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
|
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
[](https://help.router-for.me/plugin/development.html)
|
[](https://help.router-for.me/plugin/development.html)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。
|
[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 与 **OpenCode Go** 两个上游的配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard,Tab: Command Code / OpenCode Go / All)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
- [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard)
|
- [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard)
|
||||||
- [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get)
|
- [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get)
|
||||||
- [3. 管理 API: 测试用量 (`POST`)](#3-管理-api-测试用量-post)
|
- [3. 管理 API: 测试用量 (`POST`)](#3-管理-api-测试用量-post)
|
||||||
|
- [4. 管理 API: OpenCode Go 用量 (`opencode/usage`)](#4-管理-api-opencode-go-用量-opencodeusage)
|
||||||
|
- [5. 管理 API: 聚合查询 (`all`)](#5-管理-api-聚合查询-all)
|
||||||
- [用量数据结构说明](#用量数据结构说明)
|
- [用量数据结构说明](#用量数据结构说明)
|
||||||
- [开发与测试](#开发与测试)
|
- [开发与测试](#开发与测试)
|
||||||
- [许可证](#许可证)
|
- [许可证](#许可证)
|
||||||
@@ -45,6 +47,11 @@
|
|||||||
- 页面挂载于 `/v0/resource/plugins/commandcode/quota`。
|
- 页面挂载于 `/v0/resource/plugins/commandcode/quota`。
|
||||||
- 零外部 CDN 依赖,纯内置 HTML + CSS + JS,深色/浅色模式自适应。
|
- 零外部 CDN 依赖,纯内置 HTML + CSS + JS,深色/浅色模式自适应。
|
||||||
- 具有进度条颜色变化、5小时/周限额卡片、秒级动态重置倒计时、同源 `localStorage` 鉴权与一键刷新。
|
- 具有进度条颜色变化、5小时/周限额卡片、秒级动态重置倒计时、同源 `localStorage` 鉴权与一键刷新。
|
||||||
|
- Tab 切换:Command Code / OpenCode Go / All(`#opencode` / `#all` hash 记忆状态)。
|
||||||
|
6. **OpenCode Go 用量查询 (v0.3.0+)**:
|
||||||
|
- 上游接口:`GET https://opencode.ai/zen/go/v1/usage`,`Authorization: Bearer` 认证(同样走 `host.http.do` 优先 + `net/http` 兜底)。
|
||||||
|
- 解析 rolling(5h)/ weekly / monthly 三个窗口的 `status`/`percent`/`resetsAt`,容忍未知 status 值。
|
||||||
|
- 聚合端点 `/plugins/commandcode/all` 一次返回两个 provider,部分失败不拖死另一 provider。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +83,8 @@
|
|||||||
└──────────────────────────────┼─────────────────────────┘
|
└──────────────────────────────┼─────────────────────────┘
|
||||||
│ Upstream HTTPS
|
│ Upstream HTTPS
|
||||||
▼
|
▼
|
||||||
https://api.commandcode.ai/internal/billing/credits
|
https://api.commandcode.ai/internal/billing/credits
|
||||||
|
https://opencode.ai/zen/go/v1/usage (v0.3.0+)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -126,6 +134,11 @@ plugins:
|
|||||||
priority: 1
|
priority: 1
|
||||||
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
|
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
|
||||||
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
|
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
|
||||||
|
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+)
|
||||||
|
# v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口
|
||||||
|
opencode_api_keys:
|
||||||
|
- "sk-KEY1..."
|
||||||
|
- "sk-KEY2..."
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -135,11 +148,11 @@ plugins:
|
|||||||
### 1. 浏览器资源页 (`QuotaCard`)
|
### 1. 浏览器资源页 (`QuotaCard`)
|
||||||
|
|
||||||
- **访问路径**:`GET http://<cpa-host>:8317/v0/resource/plugins/commandcode/quota`
|
- **访问路径**:`GET http://<cpa-host>:8317/v0/resource/plugins/commandcode/quota`
|
||||||
- **菜单名**:`Command Code 配额`
|
- **菜单名**:`用量配额`
|
||||||
- **说明**:
|
- **说明**:
|
||||||
- 资源请求本身无需经过管理认证,可在浏览器中直接打开或嵌入仪表盘。
|
- 资源请求本身无需经过管理认证,可在浏览器中直接打开或嵌入仪表盘。
|
||||||
- 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/usage` 请求数据。
|
- 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/all` 请求数据(一次获取 Command Code + OpenCode Go)。
|
||||||
- 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key 或测试 Session Token。
|
- 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key、测试 Session Token 或 OpenCode API Key(仅当次请求生效,不持久化)。
|
||||||
|
|
||||||
### 2. 管理 API: 查询用量 (`GET`)
|
### 2. 管理 API: 查询用量 (`GET`)
|
||||||
|
|
||||||
@@ -198,6 +211,75 @@ plugins:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4. 管理 API: OpenCode Go 用量 (`opencode/usage`)
|
||||||
|
|
||||||
|
- **端点**:`GET /v0/management/plugins/commandcode/opencode/usage`(认证同上,仅读插件配置;凭据覆盖走 POST)
|
||||||
|
- **端点**:`POST /v0/management/plugins/commandcode/opencode/usage`
|
||||||
|
- **POST 请求体**(多 key 数组优先;scalar 为 v0.3.0 兼容):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "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 },
|
||||||
|
"weekly": { "status": "ok", "percent": 46, "exceeded": false,
|
||||||
|
"reset_at": "2026-09-21T00:00:00Z", "reset_in_seconds": 259200 },
|
||||||
|
"monthly": { "status": "ok", "percent": 23, "exceeded": false,
|
||||||
|
"reset_at": "2026-10-14T09:13:49Z", "reset_in_seconds": 1728000 }
|
||||||
|
},
|
||||||
|
"updated_at": "2026-09-16T12:00:00Z",
|
||||||
|
"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 请求体**(可只带其一;多 key 覆盖为数组):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "session_token": "...", "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **部分失败语义**: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", "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 资源页,已同版本同步更新。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 用量数据结构说明
|
## 用量数据结构说明
|
||||||
@@ -215,6 +297,11 @@ plugins:
|
|||||||
| `window_limits.five_hour.reset_at` | `string` | 5小时窗口重置时间的 RFC3339 字符串 |
|
| `window_limits.five_hour.reset_at` | `string` | 5小时窗口重置时间的 RFC3339 字符串 |
|
||||||
| `window_limits.five_hour.reset_in_seconds`| `int64` | 距离 5 小时窗口重置的剩余秒数 |
|
| `window_limits.five_hour.reset_in_seconds`| `int64` | 距离 5 小时窗口重置的剩余秒数 |
|
||||||
| `window_limits.weekly.*` | - | 每周限额对应指标(结构同 5 小时窗口) |
|
| `window_limits.weekly.*` | - | 每周限额对应指标(结构同 5 小时窗口) |
|
||||||
|
| `windows.<rolling\|weekly\|monthly>.status` | `string` | OpenCode Go 窗口状态(`"ok"`/上游其他值,未知值不报错) |
|
||||||
|
| `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 状态与失败原因 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
7830fab0eb763247602fd4815549a13eeb25841afbe2d3a047d25b9f4b9cf86d commandcode_0.2.2_linux_amd64.zip
|
|
||||||
Binary file not shown.
+343
-3
@@ -3,6 +3,7 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,12 +23,32 @@ func RegisterManagement() (ManagementRegistrationResponse, error) {
|
|||||||
Path: "/plugins/commandcode/usage",
|
Path: "/plugins/commandcode/usage",
|
||||||
Description: "Query Command Code credits and window limits usage with custom session_token",
|
Description: "Query Command Code credits and window limits usage with custom session_token",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/plugins/commandcode/opencode/usage",
|
||||||
|
Description: "Query OpenCode Go usage windows (rolling/weekly/monthly)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/plugins/commandcode/opencode/usage",
|
||||||
|
Description: "Query OpenCode Go usage windows with custom opencode_api_key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/plugins/commandcode/all",
|
||||||
|
Description: "Query both Command Code and OpenCode Go usage (aggregated, partial failures reported in errors map)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/plugins/commandcode/all",
|
||||||
|
Description: "Query both providers with custom credentials in request body",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Resources: []ResourceRoute{
|
Resources: []ResourceRoute{
|
||||||
{
|
{
|
||||||
Path: "/quota",
|
Path: "/quota",
|
||||||
Menu: "Command Code 配额",
|
Menu: "用量配额",
|
||||||
Description: "Command Code 用量与限额卡片",
|
Description: "Command Code + OpenCode Go 用量与限额卡片",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -49,7 +70,42 @@ func HandleManagement(ctx context.Context, req ManagementRequest, cfg *PluginCon
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Serve Usage API (GET / POST)
|
// 2. OpenCode Go usage API — MUST be matched before the generic /usage
|
||||||
|
// suffix match below, otherwise "/plugins/commandcode/opencode/usage"
|
||||||
|
// would be swallowed by the Command Code handler.
|
||||||
|
if strings.HasSuffix(path, "/plugins/commandcode/opencode/usage") {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodPost:
|
||||||
|
return handleOpenCodeUsage(ctx, req, cfg)
|
||||||
|
default:
|
||||||
|
return ManagementResponse{
|
||||||
|
StatusCode: http.StatusMethodNotAllowed,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"application/json"},
|
||||||
|
},
|
||||||
|
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Aggregated usage API (both providers) — does not end with "/usage",
|
||||||
|
// but registered before the generic match for clarity and future safety.
|
||||||
|
if strings.HasSuffix(path, "/plugins/commandcode/all") {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodPost:
|
||||||
|
return handleAllUsage(ctx, req, cfg)
|
||||||
|
default:
|
||||||
|
return ManagementResponse{
|
||||||
|
StatusCode: http.StatusMethodNotAllowed,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"application/json"},
|
||||||
|
},
|
||||||
|
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Command Code usage API (GET / POST) — generic suffix match kept as-is.
|
||||||
if strings.HasSuffix(path, "/plugins/commandcode/usage") || strings.HasSuffix(path, "/usage") {
|
if strings.HasSuffix(path, "/plugins/commandcode/usage") || strings.HasSuffix(path, "/usage") {
|
||||||
switch method {
|
switch method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@@ -215,3 +271,287 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
|||||||
Body: resBytes,
|
Body: resBytes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOpenCodeUsage serves GET/POST /plugins/commandcode/opencode/usage.
|
||||||
|
// Credentials can be overridden via POST body only (opencode_api_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) {
|
||||||
|
apiBase := ""
|
||||||
|
var keys []string
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 len(keys) == 0 && cfg != nil {
|
||||||
|
keys = cfg.GetOpenCodeAPIKeys()
|
||||||
|
}
|
||||||
|
if apiBase == "" && cfg != nil {
|
||||||
|
apiBase = cfg.GetOpenCodeAPIBase()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// providers sequentially (no goroutines — the host.http.do bridge's host-side
|
||||||
|
// concurrency safety cannot be verified and shared maps would race under -race).
|
||||||
|
// Partial failure: OK=true as long as at least one provider succeeds; failures
|
||||||
|
// land in the Errors map and successful fields are omitted when absent.
|
||||||
|
// HTTP status: any success → 200; all failed due to missing local credentials → 400;
|
||||||
|
// all failed due to upstream errors → 502.
|
||||||
|
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
||||||
|
sessionToken := ""
|
||||||
|
opencodeKeys := []string{}
|
||||||
|
|
||||||
|
if req.Method == http.MethodPost && len(req.Body) > 0 {
|
||||||
|
var body struct {
|
||||||
|
SessionToken string `json:"session_token"`
|
||||||
|
OpencodeAPIKeys []string `json:"opencode_api_keys"`
|
||||||
|
OpencodeAPIKey string `json:"opencode_api_key"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(req.Body, &body)
|
||||||
|
sessionToken = body.SessionToken
|
||||||
|
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 len(opencodeKeys) == 0 && cfg != nil {
|
||||||
|
opencodeKeys = cfg.GetOpenCodeAPIKeys()
|
||||||
|
}
|
||||||
|
apiBase := ""
|
||||||
|
if cfg != nil {
|
||||||
|
apiBase = cfg.GetAPIBase()
|
||||||
|
}
|
||||||
|
ocAPIBase := ""
|
||||||
|
if cfg != nil {
|
||||||
|
ocAPIBase = cfg.GetOpenCodeAPIBase()
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
resp := AllUsageResponse{OK: false, UpdatedAt: now.Format(time.RFC3339)}
|
||||||
|
errs := make(map[string]string)
|
||||||
|
localMissing := 0
|
||||||
|
upstreamFailed := 0
|
||||||
|
succeeded := 0
|
||||||
|
|
||||||
|
// Provider 1: Command Code (reuses executeUsageQuery).
|
||||||
|
ccResp, _ := executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
||||||
|
if ccResp.StatusCode == http.StatusOK {
|
||||||
|
resp.CommandCode = ccResp.Body
|
||||||
|
succeeded++
|
||||||
|
} else {
|
||||||
|
ccErr := extractErrorResponseMessage(ccResp.Body)
|
||||||
|
errs["commandcode"] = ccErr
|
||||||
|
if isLocalCredentialError(ccErr) {
|
||||||
|
localMissing++
|
||||||
|
} else {
|
||||||
|
upstreamFailed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider 2: OpenCode Go, 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 {
|
||||||
|
errs["opencode"] = fmt.Sprintf("all %d opencode keys failed", len(opencodeKeys))
|
||||||
|
upstreamFailed++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
resp.Errors = errs
|
||||||
|
}
|
||||||
|
resp.OK = succeeded > 0
|
||||||
|
|
||||||
|
statusCode := http.StatusOK
|
||||||
|
if !resp.OK {
|
||||||
|
if upstreamFailed == 0 && localMissing == len(errs) {
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
|
} else {
|
||||||
|
statusCode = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resBytes, _ := json.Marshal(resp)
|
||||||
|
return ManagementResponse{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"application/json"},
|
||||||
|
},
|
||||||
|
Body: resBytes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalCredentialError reports whether an /all provider error is a local
|
||||||
|
// configuration problem (missing credential in plugin config), as opposed to
|
||||||
|
// an upstream failure. Local-credential errors carry fixed message prefixes;
|
||||||
|
// upstream 4xx/5xx never match them, so the /all 400-vs-502 classification
|
||||||
|
// does not rely on the HTTP status alone.
|
||||||
|
func isLocalCredentialError(msg string) bool {
|
||||||
|
for _, prefix := range []string{
|
||||||
|
"session_token is required",
|
||||||
|
"opencode_api_key is required",
|
||||||
|
"no opencode api keys configured",
|
||||||
|
} {
|
||||||
|
if strings.HasPrefix(msg, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
res.StatusCode = http.StatusBadRequest
|
||||||
|
res.Error = "opencode_api_key is required. Configure opencode_api_keys in plugin config or pass it in the request"
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, key, hostCallbackID)
|
||||||
|
if errFetch != nil {
|
||||||
|
if statusCode == 0 || statusCode == http.StatusOK {
|
||||||
|
statusCode = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
res.StatusCode = statusCode
|
||||||
|
res.Error = fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error())
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
res.StatusCode = statusCode
|
||||||
|
res.Error = fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, errParse := ParseOpenCodeUsage(raw, now)
|
||||||
|
if errParse != nil {
|
||||||
|
res.StatusCode = http.StatusBadGateway
|
||||||
|
res.Error = "failed to parse opencode upstream usage: " + errParse.Error()
|
||||||
|
return res, 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.
|
||||||
|
func extractErrorResponseMessage(body []byte) string {
|
||||||
|
var parsed struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" {
|
||||||
|
return parsed.Error
|
||||||
|
}
|
||||||
|
return "unknown error"
|
||||||
|
}
|
||||||
|
|||||||
+656
-5
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegisterManagement(t *testing.T) {
|
func TestRegisterManagement(t *testing.T) {
|
||||||
@@ -15,8 +16,8 @@ func TestRegisterManagement(t *testing.T) {
|
|||||||
t.Fatalf("RegisterManagement error: %v", err)
|
t.Fatalf("RegisterManagement error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.Routes) != 2 {
|
if len(resp.Routes) != 6 {
|
||||||
t.Fatalf("len(Routes) = %d, want 2", len(resp.Routes))
|
t.Fatalf("len(Routes) = %d, want 6", len(resp.Routes))
|
||||||
}
|
}
|
||||||
if resp.Routes[0].Method != http.MethodGet || resp.Routes[0].Path != "/plugins/commandcode/usage" {
|
if resp.Routes[0].Method != http.MethodGet || resp.Routes[0].Path != "/plugins/commandcode/usage" {
|
||||||
t.Errorf("Route 0 mismatch: %+v", resp.Routes[0])
|
t.Errorf("Route 0 mismatch: %+v", resp.Routes[0])
|
||||||
@@ -24,13 +25,27 @@ func TestRegisterManagement(t *testing.T) {
|
|||||||
if resp.Routes[1].Method != http.MethodPost || resp.Routes[1].Path != "/plugins/commandcode/usage" {
|
if resp.Routes[1].Method != http.MethodPost || resp.Routes[1].Path != "/plugins/commandcode/usage" {
|
||||||
t.Errorf("Route 1 mismatch: %+v", resp.Routes[1])
|
t.Errorf("Route 1 mismatch: %+v", resp.Routes[1])
|
||||||
}
|
}
|
||||||
|
wantOpencode := []struct{ method, path string }{
|
||||||
|
{http.MethodGet, "/plugins/commandcode/opencode/usage"},
|
||||||
|
{http.MethodPost, "/plugins/commandcode/opencode/usage"},
|
||||||
|
{http.MethodGet, "/plugins/commandcode/all"},
|
||||||
|
{http.MethodPost, "/plugins/commandcode/all"},
|
||||||
|
}
|
||||||
|
for i, w := range wantOpencode {
|
||||||
|
if resp.Routes[2+i].Method != w.method || resp.Routes[2+i].Path != w.path {
|
||||||
|
t.Errorf("Route %d mismatch: got %+v, want %s %s", 2+i, resp.Routes[2+i], w.method, w.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(resp.Resources) != 1 {
|
if len(resp.Resources) != 1 {
|
||||||
t.Fatalf("len(Resources) = %d, want 1", len(resp.Resources))
|
t.Fatalf("len(Resources) = %d, want 1", len(resp.Resources))
|
||||||
}
|
}
|
||||||
if resp.Resources[0].Path != "/quota" || resp.Resources[0].Menu != "Command Code 配额" {
|
if resp.Resources[0].Path != "/quota" || resp.Resources[0].Menu != "用量配额" {
|
||||||
t.Errorf("Resource 0 mismatch: %+v", resp.Resources[0])
|
t.Errorf("Resource 0 mismatch: %+v", resp.Resources[0])
|
||||||
}
|
}
|
||||||
|
if resp.Resources[0].Description != "Command Code + OpenCode Go 用量与限额卡片" {
|
||||||
|
t.Errorf("Resource Description mismatch: %+v", resp.Resources[0])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleManagement_QuotaResource(t *testing.T) {
|
func TestHandleManagement_QuotaResource(t *testing.T) {
|
||||||
@@ -56,8 +71,11 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
|
|||||||
t.Errorf("Content-Type = %v, want text/html", ct)
|
t.Errorf("Content-Type = %v, want text/html", ct)
|
||||||
}
|
}
|
||||||
bodyStr := string(resp.Body)
|
bodyStr := string(resp.Body)
|
||||||
if !strings.Contains(bodyStr, "Command Code 配额") {
|
if !strings.Contains(bodyStr, "用量配额") {
|
||||||
t.Errorf("Body does not contain expected title")
|
t.Errorf("Body does not contain expected menu text 用量配额")
|
||||||
|
}
|
||||||
|
if !strings.Contains(bodyStr, "v0.4.2") {
|
||||||
|
t.Errorf("Body does not contain version badge v0.4.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,3 +168,636 @@ func TestHandleManagement_PostUsage(t *testing.T) {
|
|||||||
t.Errorf("MonthlyCredits = %v, want 666", usage.Credits.MonthlyCredits)
|
t.Errorf("MonthlyCredits = %v, want 666", usage.Credits.MonthlyCredits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockOpencodeUsageJSON = `{"usage":{
|
||||||
|
"rolling": {"status":"ok","percent":4, "resetsAt":"2026-09-17T06:58:53.171Z"},
|
||||||
|
"weekly": {"status":"ok","percent":46,"resetsAt":"2026-09-21T00:00:00.000Z"},
|
||||||
|
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
|
||||||
|
}}`
|
||||||
|
|
||||||
|
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
|
||||||
|
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
|
||||||
|
// (which would route it to the Command Code handler).
|
||||||
|
func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
|
||||||
|
var sawAuthHeader bool
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/usage" {
|
||||||
|
t.Errorf("unexpected path %s, want /usage (Command Code handler must not be hit)", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer sk-opencode-override" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer sk-opencode-override", got)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Cookie") != "" {
|
||||||
|
t.Errorf("unexpected Cookie header on opencode request: %q", r.Header.Get("Cookie"))
|
||||||
|
}
|
||||||
|
sawAuthHeader = true
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{
|
||||||
|
"opencode_api_key": "sk-opencode-override",
|
||||||
|
"opencode_api_base": ts.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method string
|
||||||
|
body []byte
|
||||||
|
}{
|
||||||
|
{http.MethodPost, reqBody},
|
||||||
|
// GET with configured plugin config (no query override by design).
|
||||||
|
} {
|
||||||
|
t.Run(tc.method, func(t *testing.T) {
|
||||||
|
cfg := &PluginConfig{
|
||||||
|
OpenCodeAPIKey: "sk-configured",
|
||||||
|
OpenCodeAPIBase: ts.URL,
|
||||||
|
}
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: tc.method,
|
||||||
|
Path: "/v0/management/plugins/commandcode/opencode/usage",
|
||||||
|
Body: tc.body,
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
// 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 len(usage.Keys) != 1 || !usage.Keys[0].OK {
|
||||||
|
t.Fatalf("expected exactly one successful key, got %+v", usage.Keys)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sawAuthHeader {
|
||||||
|
t.Fatal("upstream never received Authorization header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /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) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/usage" && r.Header.Get("Authorization") != "":
|
||||||
|
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
|
||||||
|
case r.URL.Path == "/internal/billing/credits":
|
||||||
|
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":888},"windowLimits":{"fiveHour":{"used":2,"cap":20}}}`))
|
||||||
|
case r.URL.Path == "/internal/usage/summary":
|
||||||
|
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 100}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &PluginConfig{
|
||||||
|
SessionToken: "configured-token",
|
||||||
|
APIBase: ts.URL,
|
||||||
|
OpenCodeAPIKey: "sk-configured",
|
||||||
|
OpenCodeAPIBase: ts.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/v0/management/plugins/commandcode/all",
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var all AllUsageResponse
|
||||||
|
if err := json.Unmarshal(resp.Body, &all); err != nil {
|
||||||
|
t.Fatalf("unmarshal body error: %v", err)
|
||||||
|
}
|
||||||
|
if !all.OK {
|
||||||
|
t.Fatal("expected ok=true when both providers succeed")
|
||||||
|
}
|
||||||
|
if len(all.CommandCode) == 0 || len(all.OpenCode) == 0 {
|
||||||
|
t.Fatalf("expected both provider payloads, got commandcode=%d bytes opencode=%d bytes",
|
||||||
|
len(all.CommandCode), len(all.OpenCode))
|
||||||
|
}
|
||||||
|
if len(all.Errors) != 0 {
|
||||||
|
t.Errorf("expected empty errors map, got %v", all.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccUsage FormattedUsageResponse
|
||||||
|
if err := json.Unmarshal(all.CommandCode, &ccUsage); err != nil || !ccUsage.OK {
|
||||||
|
t.Errorf("commandcode payload invalid: err=%v usage=%+v", err, ccUsage)
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// provider's field is omitted and the error lands in the errors map.
|
||||||
|
func TestHandleManagement_AllUsage_PartialFailure(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/usage" && r.Header.Get("Authorization") != "":
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"upstream exploded"}`))
|
||||||
|
case r.URL.Path == "/internal/billing/credits":
|
||||||
|
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":888},"windowLimits":{"fiveHour":{"used":2,"cap":20}}}`))
|
||||||
|
case r.URL.Path == "/internal/usage/summary":
|
||||||
|
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 100}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &PluginConfig{
|
||||||
|
SessionToken: "configured-token",
|
||||||
|
APIBase: ts.URL,
|
||||||
|
OpenCodeAPIKey: "sk-configured",
|
||||||
|
OpenCodeAPIBase: ts.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/plugins/commandcode/all",
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("StatusCode = %d, want 200 (partial failure), body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var all AllUsageResponse
|
||||||
|
if err := json.Unmarshal(resp.Body, &all); err != nil {
|
||||||
|
t.Fatalf("unmarshal body error: %v", err)
|
||||||
|
}
|
||||||
|
if !all.OK {
|
||||||
|
t.Error("expected ok=true despite one provider failing")
|
||||||
|
}
|
||||||
|
if len(all.CommandCode) == 0 {
|
||||||
|
t.Error("expected successful commandcode payload to be present")
|
||||||
|
}
|
||||||
|
if _, present := all.Errors["opencode"]; !present {
|
||||||
|
t.Errorf("expected errors[opencode] to be set, got %v", all.Errors)
|
||||||
|
}
|
||||||
|
// 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`) {
|
||||||
|
t.Errorf("opencode field serialized as null: %s", string(resp.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All providers fail because credentials are missing → 400.
|
||||||
|
func TestHandleManagement_AllUsage_AllMissingConfig(t *testing.T) {
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/v0/management/plugins/commandcode/all",
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("StatusCode = %d, want 400, body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var all AllUsageResponse
|
||||||
|
if err := json.Unmarshal(resp.Body, &all); err != nil {
|
||||||
|
t.Fatalf("unmarshal body error: %v", err)
|
||||||
|
}
|
||||||
|
if all.OK {
|
||||||
|
t.Error("expected ok=false")
|
||||||
|
}
|
||||||
|
if _, present := all.Errors["commandcode"]; !present {
|
||||||
|
t.Errorf("expected errors[commandcode], got %v", all.Errors)
|
||||||
|
}
|
||||||
|
if _, present := all.Errors["opencode"]; !present {
|
||||||
|
t.Errorf("expected errors[opencode], got %v", all.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for the acceptance review finding: an upstream 400 passed
|
||||||
|
// through by executeUsageQuery must NOT be classified as a local
|
||||||
|
// configuration problem — all-upstream-failure must yield 502, not 400.
|
||||||
|
func TestHandleManagement_AllUsage_Upstream400NotMisclassified(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"bad request from upstream"}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
|
||||||
|
cfgYAML := []byte("session_token: testtoken\napi_base: " + ts.URL + "\nopencode_api_key: sk-test\nopencode_api_base: " + ts.URL + "\n")
|
||||||
|
cfg := NewPlugin()
|
||||||
|
if err := cfg.config.UpdateFromYAML(cfgYAML); err != nil {
|
||||||
|
t.Fatalf("UpdateFromYAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := ManagementRequest{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/v0/management/plugins/commandcode/all",
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, cfg.config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
|
t.Fatalf("StatusCode = %d, want 502 (upstream 400 must not be misread as local missing config), body=%s", resp.StatusCode, string(resp.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /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{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/v0/management/plugins/commandcode/nonsense",
|
||||||
|
}
|
||||||
|
resp, err := HandleManagement(context.Background(), req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleManagement error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("StatusCode = %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+97
-6
@@ -13,7 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
PluginID = "commandcode"
|
PluginID = "commandcode"
|
||||||
PluginName = "commandcode"
|
PluginName = "commandcode"
|
||||||
PluginVersion = "0.2.2"
|
PluginVersion = "0.4.2"
|
||||||
PluginAuthor = "zgs225"
|
PluginAuthor = "zgs225"
|
||||||
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
||||||
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
||||||
@@ -22,9 +22,12 @@ const (
|
|||||||
|
|
||||||
// PluginConfig holds the runtime configuration parsed from YAML.
|
// PluginConfig holds the runtime configuration parsed from YAML.
|
||||||
type PluginConfig struct {
|
type PluginConfig struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
SessionToken string `yaml:"session_token" json:"session_token"`
|
SessionToken string `yaml:"session_token" json:"session_token"`
|
||||||
APIBase string `yaml:"api_base" json:"api_base"`
|
APIBase string `yaml:"api_base" json:"api_base"`
|
||||||
|
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
|
||||||
|
OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"`
|
||||||
|
OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateFromYAML updates the configuration from raw YAML bytes.
|
// UpdateFromYAML updates the configuration from raw YAML bytes.
|
||||||
@@ -33,8 +36,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var tmp struct {
|
var tmp struct {
|
||||||
SessionToken string `yaml:"session_token"`
|
SessionToken string `yaml:"session_token"`
|
||||||
APIBase string `yaml:"api_base"`
|
APIBase string `yaml:"api_base"`
|
||||||
|
OpenCodeAPIKey string `yaml:"opencode_api_key"`
|
||||||
|
OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
|
||||||
|
OpenCodeAPIBase string `yaml:"opencode_api_base"`
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(raw, &tmp); err != nil {
|
if err := yaml.Unmarshal(raw, &tmp); err != nil {
|
||||||
return fmt.Errorf("unmarshal config_yaml: %w", err)
|
return fmt.Errorf("unmarshal config_yaml: %w", err)
|
||||||
@@ -49,6 +55,23 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
|||||||
if tmp.APIBase != "" {
|
if tmp.APIBase != "" {
|
||||||
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
|
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
|
||||||
}
|
}
|
||||||
|
if tmp.OpenCodeAPIKey != "" {
|
||||||
|
// OpenCode API key is a plain Bearer token; do not run it through
|
||||||
|
// ExtractSessionToken (that is Command Code cookie specific).
|
||||||
|
c.OpenCodeAPIKey = strings.TrimSpace(tmp.OpenCodeAPIKey)
|
||||||
|
}
|
||||||
|
// 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, "/")
|
||||||
|
}
|
||||||
if c.APIBase == "" {
|
if c.APIBase == "" {
|
||||||
c.APIBase = DefaultAPIBase
|
c.APIBase = DefaultAPIBase
|
||||||
}
|
}
|
||||||
@@ -79,6 +102,59 @@ func (c *PluginConfig) GetAPIBase() string {
|
|||||||
return c.APIBase
|
return c.APIBase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if c.OpenCodeAPIBase == "" {
|
||||||
|
return DefaultOpenCodeAPIBase
|
||||||
|
}
|
||||||
|
return c.OpenCodeAPIBase
|
||||||
|
}
|
||||||
|
|
||||||
// Plugin encapsulates the Command Code plugin instance.
|
// Plugin encapsulates the Command Code plugin instance.
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
config *PluginConfig
|
config *PluginConfig
|
||||||
@@ -148,6 +224,21 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
|
|||||||
Type: "string",
|
Type: "string",
|
||||||
Description: "Command Code API base URL (default: https://api.commandcode.ai)",
|
Description: "Command Code API base URL (default: https://api.commandcode.ai)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "opencode_api_key",
|
||||||
|
Type: "string",
|
||||||
|
Description: "OpenCode Go API key (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",
|
||||||
|
Type: "string",
|
||||||
|
Description: "OpenCode Go API base URL (default: https://opencode.ai/zen/go/v1)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Capabilities: RegistrationCapability{
|
Capabilities: RegistrationCapability{
|
||||||
|
|||||||
+127
-6
@@ -46,16 +46,16 @@ api_base: "https://custom-api.commandcode.ai"
|
|||||||
t.Errorf("Capabilities.ManagementAPI = false, want true")
|
t.Errorf("Capabilities.ManagementAPI = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify config fields
|
// Verify config fields (v0.4.0: 4 → 5, adds opencode_api_keys)
|
||||||
if len(reg.Metadata.ConfigFields) != 2 {
|
if len(reg.Metadata.ConfigFields) != 5 {
|
||||||
t.Fatalf("ConfigFields len = %d, want 2", len(reg.Metadata.ConfigFields))
|
t.Fatalf("ConfigFields len = %d, want 5", len(reg.Metadata.ConfigFields))
|
||||||
}
|
}
|
||||||
fieldNames := map[string]bool{}
|
fieldNames := map[string]bool{}
|
||||||
for _, f := range reg.Metadata.ConfigFields {
|
for _, f := range reg.Metadata.ConfigFields {
|
||||||
fieldNames[f.Name] = true
|
fieldNames[f.Name] = true
|
||||||
}
|
}
|
||||||
if !fieldNames["session_token"] || !fieldNames["api_base"] {
|
if !fieldNames["session_token"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] {
|
||||||
t.Errorf("ConfigFields missing session_token or api_base: %+v", reg.Metadata.ConfigFields)
|
t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify config parsed
|
// Verify config parsed
|
||||||
@@ -65,7 +65,6 @@ api_base: "https://custom-api.commandcode.ai"
|
|||||||
if p.config.GetAPIBase() != "https://custom-api.commandcode.ai" {
|
if p.config.GetAPIBase() != "https://custom-api.commandcode.ai" {
|
||||||
t.Errorf("APIBase = %q, want https://custom-api.commandcode.ai", p.config.GetAPIBase())
|
t.Errorf("APIBase = %q, want https://custom-api.commandcode.ai", p.config.GetAPIBase())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test plugin.reconfigure
|
// Test plugin.reconfigure
|
||||||
reconfYAML := []byte(`
|
reconfYAML := []byte(`
|
||||||
session_token: "new-token-abc"
|
session_token: "new-token-abc"
|
||||||
@@ -135,3 +134,125 @@ func TestEnvelopeError(t *testing.T) {
|
|||||||
t.Errorf("env.Error = %+v", env.Error)
|
t.Errorf("env.Error = %+v", env.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPluginConfig_OpenCode(t *testing.T) {
|
||||||
|
p := NewPlugin()
|
||||||
|
configYAML := []byte("opencode_api_key: \" sk-opencode-123 \"\nopencode_api_base: \"https://custom.oc.example/v1/\"\n")
|
||||||
|
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
|
||||||
|
|
||||||
|
if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.register) error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.config.GetOpenCodeAPIKey(); got != "sk-opencode-123" {
|
||||||
|
t.Errorf("OpenCodeAPIKey = %q, want sk-opencode-123", got)
|
||||||
|
}
|
||||||
|
if got := p.config.GetOpenCodeAPIBase(); got != "https://custom.oc.example/v1" {
|
||||||
|
t.Errorf("OpenCodeAPIBase = %q, want https://custom.oc.example/v1 (trailing slash trimmed)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Command Code cookie string must NOT be run through ExtractSessionToken.
|
||||||
|
cookieLike := []byte("opencode_api_key: \"sk-raw-bearer-value\"\n")
|
||||||
|
req2, _ := json.Marshal(LifecycleRequest{ConfigYAML: cookieLike})
|
||||||
|
if _, err := p.HandleMethod("plugin.reconfigure", req2); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
|
||||||
|
}
|
||||||
|
if got := p.config.GetOpenCodeAPIKey(); got != "sk-raw-bearer-value" {
|
||||||
|
t.Errorf("OpenCodeAPIKey = %q, want sk-raw-bearer-value (raw, no cookie extraction)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty config falls back to the default base.
|
||||||
|
empty := NewPlugin()
|
||||||
|
if got := empty.config.GetOpenCodeAPIBase(); got != DefaultOpenCodeAPIBase {
|
||||||
|
t.Errorf("default OpenCodeAPIBase = %q, want %q", got, DefaultOpenCodeAPIBase)
|
||||||
|
}
|
||||||
|
if got := empty.config.GetOpenCodeAPIKey(); got != "" {
|
||||||
|
t.Errorf("default OpenCodeAPIKey = %q, want empty", got)
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+722
-44
@@ -10,7 +10,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Command Code 配额与用量 - CLIProxyAPI</title>
|
<title>用量配额 - CLIProxyAPI</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-page: #f8fafc;
|
--bg-page: #f8fafc;
|
||||||
@@ -217,11 +217,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-subtitle {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-group {
|
.action-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -622,6 +617,266 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab Bar (Command Code | OpenCode Go | All) */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-section.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
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 */
|
||||||
|
.all-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.all-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-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);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-provider-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-provider-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-provider-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-summary-value {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-grid .progress-track {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-tab provider failure error cards */
|
||||||
|
.error-card {
|
||||||
|
display: none;
|
||||||
|
background: var(--danger-subtle);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card-title {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card-msg {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer-bar {
|
.footer-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -662,11 +917,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="brand-title">
|
<div class="brand-title">
|
||||||
Command Code 配额
|
用量配额
|
||||||
<span class="version-tag">v0.2.2</span>
|
<span class="version-tag">v0.4.2</span>
|
||||||
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-subtitle">CLIProxyAPI 实时限额与 Credits 用量监控</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -694,6 +947,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div id="tabBar" class="tab-bar">
|
||||||
|
<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 active" data-tab="all">All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Alert Message -->
|
<!-- Alert Message -->
|
||||||
<div id="alertBox" class="alert alert-danger">
|
<div id="alertBox" class="alert alert-danger">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||||
@@ -715,12 +975,33 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
<label for="inputSessionToken">Command Code 会话 Token (Session Token 测试)</label>
|
<label for="inputSessionToken">Command Code 会话 Token (Session Token 测试)</label>
|
||||||
<input type="password" id="inputSessionToken" class="form-control" placeholder="覆盖测试: __Secure-commandcode_prod_.session_token" />
|
<input type="password" id="inputSessionToken" class="form-control" placeholder="覆盖测试: __Secure-commandcode_prod_.session_token" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="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>
|
||||||
<div style="margin-top: 14px; display: flex; justify-content: flex-end; gap: 10px;">
|
<div style="margin-top: 14px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||||
<button id="btnSaveConfig" class="btn btn-primary">保存并重新获取用量</button>
|
<button id="btnSaveConfig" class="btn btn-primary">保存并重新获取用量</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Command Code -->
|
||||||
|
<div id="sectionCommandcode" class="tab-section">
|
||||||
|
<div id="ccErrorCard" class="error-card">
|
||||||
|
<div class="error-card-title">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||||
|
<span>Command Code 查询失败</span>
|
||||||
|
</div>
|
||||||
|
<div id="ccErrorMsg" class="error-card-msg">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ccContent">
|
||||||
|
<div class="cc-tab-head">
|
||||||
|
<span class="cc-tab-title">Command Code</span>
|
||||||
|
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Overview Metrics -->
|
<!-- Overview Metrics -->
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
@@ -847,18 +1128,56 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /Tab: Command Code -->
|
||||||
|
|
||||||
|
<!-- Tab: OpenCode Go -->
|
||||||
|
<div id="sectionOpencode" class="tab-section">
|
||||||
|
<div id="ocErrorCard" class="error-card">
|
||||||
|
<div class="error-card-title">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||||
|
<span>OpenCode Go 查询失败</span>
|
||||||
|
</div>
|
||||||
|
<div id="ocErrorMsg" class="error-card-msg">-</div>
|
||||||
|
</div>
|
||||||
|
<div id="ocContent"></div>
|
||||||
|
</div>
|
||||||
|
<!-- /Tab: OpenCode Go -->
|
||||||
|
|
||||||
|
<!-- Tab: All -->
|
||||||
|
<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 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 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>
|
||||||
|
<!-- /Tab: All -->
|
||||||
|
|
||||||
<!-- Footer Status -->
|
<!-- Footer Status -->
|
||||||
<div class="footer-bar">
|
<div class="footer-bar">
|
||||||
<div>最后同步时间: <span id="lastUpdated">-</span></div>
|
<div>最后同步时间: <span id="lastUpdated">-</span></div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<span id="authInfo">Provider: commandcode</span>
|
<span id="authInfo">Provider: commandcode + opencode_go</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const USAGE_ENDPOINT = "/v0/management/plugins/commandcode/usage";
|
const ALL_ENDPOINT = "/v0/management/plugins/commandcode/all";
|
||||||
|
|
||||||
// Elements
|
// Elements
|
||||||
const btnRefresh = document.getElementById("btnRefresh");
|
const btnRefresh = document.getElementById("btnRefresh");
|
||||||
@@ -869,12 +1188,29 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
const settingsDrawer = document.getElementById("settingsDrawer");
|
const settingsDrawer = document.getElementById("settingsDrawer");
|
||||||
const inputMgmtKey = document.getElementById("inputMgmtKey");
|
const inputMgmtKey = document.getElementById("inputMgmtKey");
|
||||||
const inputSessionToken = document.getElementById("inputSessionToken");
|
const inputSessionToken = document.getElementById("inputSessionToken");
|
||||||
|
const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys");
|
||||||
const alertBox = document.getElementById("alertBox");
|
const alertBox = document.getElementById("alertBox");
|
||||||
const alertMsg = document.getElementById("alertMsg");
|
const alertMsg = document.getElementById("alertMsg");
|
||||||
const statusBadge = document.getElementById("statusBadge");
|
const statusBadge = document.getElementById("statusBadge");
|
||||||
const statusText = document.getElementById("statusText");
|
const statusText = document.getElementById("statusText");
|
||||||
const planBadge = document.getElementById("planBadge");
|
const planBadge = document.getElementById("planBadge");
|
||||||
|
|
||||||
|
// Tab sections and per-provider error cards
|
||||||
|
const ccContent = document.getElementById("ccContent");
|
||||||
|
const ccErrorCard = document.getElementById("ccErrorCard");
|
||||||
|
const ccErrorMsg = document.getElementById("ccErrorMsg");
|
||||||
|
const ocContent = document.getElementById("ocContent");
|
||||||
|
const ocErrorCard = document.getElementById("ocErrorCard");
|
||||||
|
const ocErrorMsg = document.getElementById("ocErrorMsg");
|
||||||
|
|
||||||
|
// All tab elements
|
||||||
|
const allBodyCommandcode = document.getElementById("allBodyCommandcode");
|
||||||
|
const allBadgeCommandcode = document.getElementById("allBadgeCommandcode");
|
||||||
|
const allBadgeTextCommandcode = document.getElementById("allBadgeTextCommandcode");
|
||||||
|
const allBodyOpencode = document.getElementById("allBodyOpencode");
|
||||||
|
const allBadgeOpencode = document.getElementById("allBadgeOpencode");
|
||||||
|
const allBadgeTextOpencode = document.getElementById("allBadgeTextOpencode");
|
||||||
|
|
||||||
const valMonthlyCredits = document.getElementById("valMonthlyCredits");
|
const valMonthlyCredits = document.getElementById("valMonthlyCredits");
|
||||||
const valOpensourceCredits = document.getElementById("valOpensourceCredits");
|
const valOpensourceCredits = document.getElementById("valOpensourceCredits");
|
||||||
const valTotalCredits = document.getElementById("valTotalCredits");
|
const valTotalCredits = document.getElementById("valTotalCredits");
|
||||||
@@ -904,11 +1240,17 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
const timerWeekly = document.getElementById("timerWeekly");
|
const timerWeekly = document.getElementById("timerWeekly");
|
||||||
const lastUpdated = document.getElementById("lastUpdated");
|
const lastUpdated = document.getElementById("lastUpdated");
|
||||||
|
|
||||||
let monthlyTargetTime = null;
|
|
||||||
let fiveHourTargetTime = null;
|
let fiveHourTargetTime = null;
|
||||||
let weeklyTargetTime = null;
|
let weeklyTargetTime = null;
|
||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
|
|
||||||
|
// Per-provider render state (drives tab badge aggregation)
|
||||||
|
const providerState = {
|
||||||
|
commandcode: { level: "unknown", err: null, data: null },
|
||||||
|
opencode: { level: "unknown", err: null, data: null }
|
||||||
|
};
|
||||||
|
let activeTab = "all";
|
||||||
|
|
||||||
function getStoredManagementKey() {
|
function getStoredManagementKey() {
|
||||||
if (inputMgmtKey.value.trim()) {
|
if (inputMgmtKey.value.trim()) {
|
||||||
return inputMgmtKey.value.trim();
|
return inputMgmtKey.value.trim();
|
||||||
@@ -965,6 +1307,95 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
return n < 10 ? "0" + n : n;
|
return n < 10 ? "0" + n : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a clamped number in [0,100], or null when missing/invalid
|
||||||
|
function clampPercent(v) {
|
||||||
|
if (v === null || v === undefined || v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
if (!isFinite(n)) return null;
|
||||||
|
return Math.min(100, Math.max(0, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown status values never error: only explicit exceeded flag,
|
||||||
|
// status "exceeded", or percent >= 100 count as exceeded
|
||||||
|
function levelOf(pct, exceeded, status) {
|
||||||
|
if (exceeded || status === "exceeded" || (pct !== null && pct >= 100)) return "exceeded";
|
||||||
|
if (pct !== null && pct >= 80) return "warning";
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_RANK = { unknown: -1, online: 0, warning: 1, error: 2, exceeded: 3 };
|
||||||
|
const BADGE_TEXT = {
|
||||||
|
unknown: "正在检查...",
|
||||||
|
online: "正常运行 (Normal)",
|
||||||
|
warning: "配额紧张 (Warning)",
|
||||||
|
exceeded: "已达限额 (Exceeded)",
|
||||||
|
error: "查询错误"
|
||||||
|
};
|
||||||
|
|
||||||
|
function badgeVisualClass(level) {
|
||||||
|
// error reuses the danger/exceeded look
|
||||||
|
return level === "error" ? "exceeded" : level === "unknown" ? "" : level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobalBadge() {
|
||||||
|
let p;
|
||||||
|
if (activeTab === "commandcode") {
|
||||||
|
p = providerState.commandcode;
|
||||||
|
} else if (activeTab === "opencode") {
|
||||||
|
p = providerState.opencode;
|
||||||
|
} else {
|
||||||
|
const a = providerState.commandcode;
|
||||||
|
const b = providerState.opencode;
|
||||||
|
p = LEVEL_RANK[a.level] >= LEVEL_RANK[b.level] ? a : b;
|
||||||
|
}
|
||||||
|
const visual = badgeVisualClass(p.level);
|
||||||
|
statusBadge.className = visual ? "status-badge " + visual : "status-badge";
|
||||||
|
statusText.textContent = BADGE_TEXT[p.level] || "正在检查...";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProviderStatus(provider, level) {
|
||||||
|
providerState[provider].level = level;
|
||||||
|
providerState[provider].err = null;
|
||||||
|
updateGlobalBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryRow(label, value) {
|
||||||
|
return '<div class="all-summary-row"><span class="all-summary-label">' + label + '</span><span class="all-summary-value">' + value + '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function miniBar(pct, level) {
|
||||||
|
const cls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
|
||||||
|
const w = pct === null ? 0 : pct;
|
||||||
|
return '<div class="progress-track"><div class="progress-bar' + cls + '" style="width:' + w + '%"></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProviderError(provider, msg) {
|
||||||
|
providerState[provider].level = "error";
|
||||||
|
providerState[provider].err = msg;
|
||||||
|
providerState[provider].data = null;
|
||||||
|
if (provider === "commandcode") {
|
||||||
|
ccContent.style.display = "none";
|
||||||
|
ccErrorMsg.textContent = msg;
|
||||||
|
ccErrorCard.classList.add("show");
|
||||||
|
} else {
|
||||||
|
ocContent.style.display = "none";
|
||||||
|
ocErrorMsg.textContent = msg;
|
||||||
|
ocErrorCard.classList.add("show");
|
||||||
|
}
|
||||||
|
updateGlobalBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-window countdown elements carry their reset info in data
|
||||||
|
// attributes (data-reset-at ISO string, or data-reset-secs seconds),
|
||||||
|
// so updateTimers() can walk every tab容器's .oc-win-reset uniformly
|
||||||
function updateTimers() {
|
function updateTimers() {
|
||||||
if (monthlyTargetTime) {
|
if (monthlyTargetTime) {
|
||||||
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
|
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
|
||||||
@@ -975,10 +1406,28 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
if (weeklyTargetTime) {
|
if (weeklyTargetTime) {
|
||||||
timerWeekly.textContent = formatCountdown(weeklyTargetTime);
|
timerWeekly.textContent = formatCountdown(weeklyTargetTime);
|
||||||
}
|
}
|
||||||
|
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) : "-";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsage(data) {
|
function renderUsage(data) {
|
||||||
hideAlert();
|
providerState.commandcode.err = null;
|
||||||
|
providerState.commandcode.data = data;
|
||||||
|
ccContent.style.display = "";
|
||||||
|
ccErrorCard.classList.remove("show");
|
||||||
const credits = data.credits || (data.data && data.data.credits) || {};
|
const credits = data.credits || (data.data && data.data.credits) || {};
|
||||||
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
|
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
|
||||||
|
|
||||||
@@ -1051,27 +1500,208 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
weeklyTargetTime = null;
|
weeklyTargetTime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall Status
|
// Per-provider status; the header badge is aggregated in updateGlobalBadge()
|
||||||
|
let ccLevel;
|
||||||
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
|
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
|
||||||
statusBadge.className = "status-badge exceeded";
|
ccLevel = "exceeded";
|
||||||
statusText.textContent = "已达限额 (Exceeded)";
|
|
||||||
} else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) {
|
} else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) {
|
||||||
statusBadge.className = "status-badge warning";
|
ccLevel = "warning";
|
||||||
statusText.textContent = "配额紧张 (Warning)";
|
|
||||||
} else {
|
} else {
|
||||||
statusBadge.className = "status-badge online";
|
ccLevel = "online";
|
||||||
statusText.textContent = "正常运行 (Normal)";
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedAtStr = data.updated_at || (data.data && data.data.updated_at);
|
|
||||||
if (updatedAtStr) {
|
|
||||||
lastUpdated.textContent = new Date(updatedAtStr).toLocaleString();
|
|
||||||
} else {
|
|
||||||
lastUpdated.textContent = new Date().toLocaleString();
|
|
||||||
}
|
}
|
||||||
|
setProviderStatus("commandcode", ccLevel);
|
||||||
|
|
||||||
updateTimers();
|
updateTimers();
|
||||||
}
|
}
|
||||||
|
function renderOpencode(data) {
|
||||||
|
providerState.opencode.err = null;
|
||||||
|
providerState.opencode.data = data;
|
||||||
|
|
||||||
|
// 多 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");
|
||||||
|
|
||||||
|
// 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" }
|
||||||
|
];
|
||||||
|
const rank = { online: 0, warning: 1, exceeded: 2 };
|
||||||
|
// provider 级别 = 全部 key 最差(失败 key 按 exceeded 视觉计入)
|
||||||
|
let worst = "online";
|
||||||
|
let html = "";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显 "-"
|
||||||
|
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" : "";
|
||||||
|
|
||||||
|
rowsHtml += '<div class="oc-win-row">' +
|
||||||
|
'<span class="oc-win-name">' + def.name + '</span>' +
|
||||||
|
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
|
||||||
|
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
|
||||||
|
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
const chipText = keyWorst === "exceeded" ? "超限" : keyWorst === "warning" ? "紧张" : "正常";
|
||||||
|
html += '<div class="oc-key-group">' +
|
||||||
|
'<div class="oc-key-head"><span class="oc-key-id">' + keyId + '</span>' +
|
||||||
|
'<span class="oc-key-chip status-badge ' + keyWorst + '"><span class="status-dot"></span><span>' + chipText + '</span></span></div>' +
|
||||||
|
'<div class="oc-key-body">' + rowsHtml + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
} else {
|
||||||
|
// 失败 key 无 windows 字段:只渲染 key 头 + 错误行
|
||||||
|
if (rank.exceeded > rank[worst]) worst = "exceeded";
|
||||||
|
const is401 = k.status_code === 401;
|
||||||
|
const label = is401 ? "凭据无效" : "查询错误";
|
||||||
|
const statusPart = k.status_code ? "上游 " + esc(String(k.status_code)) : "查询失败";
|
||||||
|
const errTail = k.error ? ":" + esc(k.error) : "";
|
||||||
|
const tip = is401 ? ",请检查该 key 或从配置中移除" : "";
|
||||||
|
html += '<div class="oc-key-group">' +
|
||||||
|
'<div class="oc-key-head"><span class="oc-key-id">' + keyId + '</span>' +
|
||||||
|
'<span class="oc-key-chip status-badge exceeded"><span class="status-dot"></span><span>' + label + '</span></span></div>' +
|
||||||
|
'<div class="oc-key-error">' + label + '(' + statusPart + ')' + errTail + tip + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { html: html, worst: worst };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllTab() {
|
||||||
|
const cc = providerState.commandcode;
|
||||||
|
const oc = providerState.opencode;
|
||||||
|
|
||||||
|
// Command Code summary card
|
||||||
|
if (cc.err) {
|
||||||
|
allBadgeCommandcode.className = "status-badge exceeded";
|
||||||
|
allBadgeTextCommandcode.textContent = "查询错误";
|
||||||
|
allBodyCommandcode.innerHTML = '<div class="error-card-title">Command Code 查询失败</div><div class="error-card-msg">' + esc(cc.err) + '</div>';
|
||||||
|
} else if (cc.data) {
|
||||||
|
const data = cc.data;
|
||||||
|
const credits = data.credits || (data.data && data.data.credits) || {};
|
||||||
|
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
|
||||||
|
const plan = data.plan || (data.data && data.data.plan);
|
||||||
|
const planName = plan ? (plan.name || (typeof plan === "string" ? plan : "Unknown")) : "-";
|
||||||
|
const windowsDef = [
|
||||||
|
{ name: "Monthly", o: limits.monthly || {} },
|
||||||
|
{ name: "5-Hour", o: limits.five_hour || {} },
|
||||||
|
{ name: "Weekly", o: limits.weekly || {} }
|
||||||
|
];
|
||||||
|
let worstName = "-";
|
||||||
|
let worstPct = null;
|
||||||
|
let worstLevel = "online";
|
||||||
|
windowsDef.forEach(function (d) {
|
||||||
|
const pct = clampPercent(d.o.percentage);
|
||||||
|
const level = levelOf(pct, d.o.exceeded, "");
|
||||||
|
if (worstPct === null || (pct !== null && pct > worstPct) || level === "exceeded") {
|
||||||
|
worstName = d.name;
|
||||||
|
worstPct = pct === null ? 0 : pct;
|
||||||
|
worstLevel = level;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
|
||||||
|
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
|
||||||
|
allBodyCommandcode.innerHTML =
|
||||||
|
summaryRow("Plan", esc(planName)) +
|
||||||
|
summaryRow("Total Credits", formatUSD(credits.total_credits)) +
|
||||||
|
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
|
||||||
|
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
|
||||||
|
miniBar(worstPct, worstLevel);
|
||||||
|
} else {
|
||||||
|
allBadgeCommandcode.className = "status-badge";
|
||||||
|
allBadgeTextCommandcode.textContent = "尚未加载";
|
||||||
|
allBodyCommandcode.innerHTML = '<div class="all-summary-value">尚未加载</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCode Go summary card
|
||||||
|
if (oc.err) {
|
||||||
|
allBadgeOpencode.className = "status-badge exceeded";
|
||||||
|
allBadgeTextOpencode.textContent = "查询错误";
|
||||||
|
allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>';
|
||||||
|
} else if (oc.data) {
|
||||||
|
const data = oc.data;
|
||||||
|
// 多 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;
|
||||||
|
} else {
|
||||||
|
allBadgeOpencode.className = "status-badge";
|
||||||
|
allBadgeTextOpencode.textContent = "尚未加载";
|
||||||
|
allBodyOpencode.innerHTML = '<div class="all-summary-value">尚未加载</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTab(tab, updateHash) {
|
||||||
|
activeTab = tab;
|
||||||
|
const tabBtns = document.querySelectorAll(".tab-btn");
|
||||||
|
for (let i = 0; i < tabBtns.length; i++) {
|
||||||
|
tabBtns[i].classList.toggle("active", tabBtns[i].getAttribute("data-tab") === tab);
|
||||||
|
}
|
||||||
|
document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode");
|
||||||
|
document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode");
|
||||||
|
document.getElementById("sectionAll").classList.toggle("active", tab === "all");
|
||||||
|
updateGlobalBadge();
|
||||||
|
if (updateHash) {
|
||||||
|
if (tab === "all") {
|
||||||
|
// All 为默认 tab:激活 All 时清掉 hash(刷新回到默认)
|
||||||
|
history.replaceState(null, "", location.pathname + location.search);
|
||||||
|
} else {
|
||||||
|
location.hash = tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchUsage() {
|
async function fetchUsage() {
|
||||||
refreshIcon.classList.add("spin");
|
refreshIcon.classList.add("spin");
|
||||||
@@ -1079,28 +1709,33 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
|
|
||||||
const mgmtKey = getStoredManagementKey();
|
const mgmtKey = getStoredManagementKey();
|
||||||
const overrideToken = inputSessionToken.value.trim();
|
const overrideToken = inputSessionToken.value.trim();
|
||||||
|
const overrideOpenCodeKeys = inputOpenCodeKeys.value.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
"Accept": "application/json"
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
};
|
};
|
||||||
if (mgmtKey) {
|
if (mgmtKey) {
|
||||||
headers["Authorization"] = "Bearer " + mgmtKey;
|
headers["Authorization"] = "Bearer " + mgmtKey;
|
||||||
headers["X-Management-Key"] = mgmtKey;
|
headers["X-Management-Key"] = mgmtKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
let method = "GET";
|
// 整页只发一次 /all 请求,一次拿两个 provider;
|
||||||
let body = null;
|
// 覆盖凭据仅在填写时才进 body,不持久化
|
||||||
|
const bodyObj = {};
|
||||||
if (overrideToken) {
|
if (overrideToken) {
|
||||||
method = "POST";
|
bodyObj.session_token = overrideToken;
|
||||||
headers["Content-Type"] = "application/json";
|
}
|
||||||
body = JSON.stringify({ session_token: overrideToken });
|
// 非空才进 body:list 优先(后端仍接受旧 scalar 字段,前端不再发送)
|
||||||
|
if (overrideOpenCodeKeys.length > 0) {
|
||||||
|
bodyObj.opencode_api_keys = overrideOpenCodeKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(USAGE_ENDPOINT, {
|
const res = await fetch(ALL_ENDPOINT, {
|
||||||
method: method,
|
method: "POST",
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: body
|
body: JSON.stringify(bodyObj)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
@@ -1112,19 +1747,46 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok || json.ok === false) {
|
if (!res.ok) {
|
||||||
const msg = json.error || (json.message ? json.message : "获取配额失败 (HTTP " + res.status + ")");
|
const msg = (json && (json.error || json.message)) || "获取配额失败 (HTTP " + res.status + ")";
|
||||||
|
showProviderError("commandcode", msg);
|
||||||
|
showProviderError("opencode", msg);
|
||||||
showAlert(msg);
|
showAlert(msg);
|
||||||
statusBadge.className = "status-badge exceeded";
|
|
||||||
statusText.textContent = "查询错误";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUsage(json);
|
// 部分失败不阻塞:缺失/失败 provider 在各自 tab 内渲染错误卡片
|
||||||
|
let anyOk = false;
|
||||||
|
if (json.commandcode && json.commandcode.ok !== false) {
|
||||||
|
renderUsage(json.commandcode);
|
||||||
|
anyOk = true;
|
||||||
|
} else {
|
||||||
|
showProviderError("commandcode", (json.errors && json.errors.commandcode) || "Command Code 查询失败");
|
||||||
|
}
|
||||||
|
if (json.opencode && json.opencode.ok !== false) {
|
||||||
|
renderOpencode(json.opencode);
|
||||||
|
anyOk = true;
|
||||||
|
} else {
|
||||||
|
showProviderError("opencode", (json.errors && json.errors.opencode) || "OpenCode Go 查询失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyOk) {
|
||||||
|
hideAlert();
|
||||||
|
} else {
|
||||||
|
showAlert("所有数据源查询失败,请检查配置或凭据。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedStr = json.updated_at ||
|
||||||
|
(json.commandcode && json.commandcode.updated_at) ||
|
||||||
|
(json.opencode && json.opencode.updated_at);
|
||||||
|
lastUpdated.textContent = updatedStr ? new Date(updatedStr).toLocaleString() : new Date().toLocaleString();
|
||||||
|
|
||||||
|
renderAllTab();
|
||||||
|
updateTimers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
showProviderError("commandcode", "网络或同源请求错误: " + err.message);
|
||||||
|
showProviderError("opencode", "网络或同源请求错误: " + err.message);
|
||||||
showAlert("网络或同源请求错误: " + err.message);
|
showAlert("网络或同源请求错误: " + err.message);
|
||||||
statusBadge.className = "status-badge exceeded";
|
|
||||||
statusText.textContent = "连接失败";
|
|
||||||
} finally {
|
} finally {
|
||||||
refreshIcon.classList.remove("spin");
|
refreshIcon.classList.remove("spin");
|
||||||
btnRefresh.disabled = false;
|
btnRefresh.disabled = false;
|
||||||
@@ -1150,6 +1812,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
fetchUsage();
|
fetchUsage();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tab switching + hash persistence (#opencode / #all)
|
||||||
|
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
setActiveTab(btn.getAttribute("data-tab"), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Init on load
|
// Init on load
|
||||||
const initKey = localStorage.getItem("management_key") || localStorage.getItem("cpa_management_key");
|
const initKey = localStorage.getItem("management_key") || localStorage.getItem("cpa_management_key");
|
||||||
if (initKey) {
|
if (initKey) {
|
||||||
@@ -1225,6 +1894,15 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
timerInterval = setInterval(updateTimers, 1000);
|
timerInterval = setInterval(updateTimers, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore tab from location.hash, then initial fetch
|
||||||
|
// 无 hash 默认 All:All tab 才能通过刷新后的 #all hash 恢复
|
||||||
|
const initHash = location.hash.replace(/^#/, "");
|
||||||
|
if (initHash === "opencode" || initHash === "commandcode") {
|
||||||
|
setActiveTab(initHash, false);
|
||||||
|
} else {
|
||||||
|
setActiveTab("all", false);
|
||||||
|
}
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
fetchUsage();
|
fetchUsage();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -248,3 +248,85 @@ type FormattedUsageResponse struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCodeUsageResponse reflects GET {opencode_api_base}/usage from OpenCode Go.
|
||||||
|
type OpenCodeUsageResponse struct {
|
||||||
|
Usage OpenCodeUsageWindows `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCodeUsageWindows carries the three usage windows returned by OpenCode Go.
|
||||||
|
type OpenCodeUsageWindows struct {
|
||||||
|
Rolling OpenCodeUsageWindow `json:"rolling"`
|
||||||
|
Weekly OpenCodeUsageWindow `json:"weekly"`
|
||||||
|
Monthly OpenCodeUsageWindow `json:"monthly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCodeUsageWindow represents one quota window from OpenCode Go.
|
||||||
|
// Percent is int in the observed upstream payload but parsed as float64 for tolerance.
|
||||||
|
type OpenCodeUsageWindow struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
ResetsAt string `json:"resetsAt"` // RFC3339 UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCodeFormattedWindows is the formatted OpenCode Go window section.
|
||||||
|
type OpenCodeFormattedWindows struct {
|
||||||
|
Rolling OpenCodeFormattedWindow `json:"rolling"`
|
||||||
|
Weekly OpenCodeFormattedWindow `json:"weekly"`
|
||||||
|
Monthly OpenCodeFormattedWindow `json:"monthly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCodeFormattedWindow is one formatted OpenCode Go window.
|
||||||
|
type OpenCodeFormattedWindow struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
Exceeded bool `json:"exceeded"`
|
||||||
|
ResetAt string `json:"reset_at"`
|
||||||
|
ResetInSeconds int64 `json:"reset_in_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCodeFormattedUsageResponse is the formatted OpenCode Go usage payload.
|
||||||
|
type OpenCodeFormattedUsageResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Provider string `json:"provider"` // "opencode_go"
|
||||||
|
Windows OpenCodeFormattedWindows `json:"windows"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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"`
|
||||||
|
OpenCode json.RawMessage `json:"opencode,omitempty"`
|
||||||
|
Errors map[string]string `json:"errors,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
+131
-13
@@ -14,7 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultAPIBase = "https://api.commandcode.ai"
|
DefaultAPIBase = "https://api.commandcode.ai"
|
||||||
|
DefaultOpenCodeAPIBase = "https://opencode.ai/zen/go/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPDoer abstracts HTTP requests for testing and fallback.
|
// HTTPDoer abstracts HTTP requests for testing and fallback.
|
||||||
@@ -56,16 +57,24 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
|
|||||||
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
|
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
|
||||||
cookieValue := FormatSessionCookie(cleanToken)
|
cookieValue := FormatSessionCookie(cleanToken)
|
||||||
|
|
||||||
|
headers := map[string][]string{
|
||||||
|
"Cookie": {cookieValue},
|
||||||
|
"Accept": {"application/json"},
|
||||||
|
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
|
||||||
|
}
|
||||||
|
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doUpstreamRequest is the shared transport layer: it tries the host.http.do
|
||||||
|
// bridge first (when a host caller is registered) and falls back to net/http.
|
||||||
|
// Request semantics (method, URL, headers) are fully controlled by the caller.
|
||||||
|
func doUpstreamRequest(ctx context.Context, method, url string, headers map[string][]string, hostCallbackID string) ([]byte, int, error) {
|
||||||
// 1. Try host.http.do if hostCaller is configured
|
// 1. Try host.http.do if hostCaller is configured
|
||||||
if hostCaller != nil {
|
if hostCaller != nil {
|
||||||
reqPayload := HostHTTPRequest{
|
reqPayload := HostHTTPRequest{
|
||||||
Method: http.MethodGet,
|
Method: method,
|
||||||
URL: url,
|
URL: url,
|
||||||
Headers: map[string][]string{
|
Headers: headers,
|
||||||
"Cookie": {cookieValue},
|
|
||||||
"Accept": {"application/json"},
|
|
||||||
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
|
|
||||||
},
|
|
||||||
HostCallbackID: hostCallbackID,
|
HostCallbackID: hostCallbackID,
|
||||||
}
|
}
|
||||||
rawReq, errMarshal := json.Marshal(reqPayload)
|
rawReq, errMarshal := json.Marshal(reqPayload)
|
||||||
@@ -97,13 +106,15 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback to Go net/http client
|
// 2. Fallback to Go net/http client
|
||||||
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
httpReq, errNew := http.NewRequestWithContext(ctx, method, url, nil)
|
||||||
if errNew != nil {
|
if errNew != nil {
|
||||||
return nil, http.StatusInternalServerError, fmt.Errorf("create HTTP request: %w", errNew)
|
return nil, http.StatusInternalServerError, fmt.Errorf("create HTTP request: %w", errNew)
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Cookie", cookieValue)
|
for key, values := range headers {
|
||||||
httpReq.Header.Set("Accept", "application/json")
|
for _, value := range values {
|
||||||
httpReq.Header.Set("User-Agent", fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion))
|
httpReq.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res, errDo := defaultHTTPClient.Do(httpReq)
|
res, errDo := defaultHTTPClient.Do(httpReq)
|
||||||
if errDo != nil {
|
if errDo != nil {
|
||||||
@@ -131,6 +142,113 @@ func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hos
|
|||||||
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
|
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
|
||||||
|
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
|
||||||
|
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, http.StatusBadRequest, errors.New("missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = DefaultOpenCodeAPIBase
|
||||||
|
}
|
||||||
|
url := strings.TrimRight(apiBase, "/") + "/usage"
|
||||||
|
|
||||||
|
headers := map[string][]string{
|
||||||
|
"Authorization": {"Bearer " + apiKey},
|
||||||
|
"Accept": {"application/json"},
|
||||||
|
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
|
||||||
|
}
|
||||||
|
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOpenCodeUsage parses OpenCode Go usage JSON into the formatted response.
|
||||||
|
// Unknown status values are tolerated; a resetsAt that fails to parse is not
|
||||||
|
// fatal (ResetAt stays empty and ResetInSeconds stays 0).
|
||||||
|
func ParseOpenCodeUsage(raw []byte, now time.Time) (*OpenCodeFormattedUsageResponse, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, errors.New("empty response body from upstream")
|
||||||
|
}
|
||||||
|
|
||||||
|
var upstream OpenCodeUsageResponse
|
||||||
|
if err := json.Unmarshal(raw, &upstream); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal opencode usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OpenCodeFormattedUsageResponse{
|
||||||
|
OK: true,
|
||||||
|
Provider: "opencode_go",
|
||||||
|
Windows: OpenCodeFormattedWindows{
|
||||||
|
Rolling: formatOpenCodeWindow(upstream.Usage.Rolling, now),
|
||||||
|
Weekly: formatOpenCodeWindow(upstream.Usage.Weekly, now),
|
||||||
|
Monthly: formatOpenCodeWindow(upstream.Usage.Monthly, now),
|
||||||
|
},
|
||||||
|
UpdatedAt: now.Format(time.RFC3339),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOpenCodeWindow formats a single OpenCode Go usage window.
|
||||||
|
func formatOpenCodeWindow(w OpenCodeUsageWindow, now time.Time) OpenCodeFormattedWindow {
|
||||||
|
percent := clampOpenCodePercent(w.Percent)
|
||||||
|
out := OpenCodeFormattedWindow{
|
||||||
|
Status: w.Status,
|
||||||
|
Percent: percent,
|
||||||
|
Exceeded: percent >= 100 || w.Status == "exceeded",
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.ResetsAt != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, w.ResetsAt); err == nil {
|
||||||
|
out.ResetAt = t.UTC().Format(time.RFC3339)
|
||||||
|
if diff := t.UTC().Sub(now); diff > 0 {
|
||||||
|
out.ResetInSeconds = int64(diff.Seconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parse failure is not fatal: ResetAt stays empty, ResetInSeconds stays 0.
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampOpenCodePercent clamps a percentage to [0, 100] with 2-decimal rounding.
|
||||||
|
func clampOpenCodePercent(p float64) float64 {
|
||||||
|
if p < 0 {
|
||||||
|
p = 0
|
||||||
|
}
|
||||||
|
if p > 100 {
|
||||||
|
p = 100
|
||||||
|
}
|
||||||
|
return math.Round(p*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskAPIKey masks an OpenCode Go API key for display: first 4 + "…" + last 4
|
||||||
|
// characters (e.g. "sk-L…KqYB"). Keys shorter than 8 characters are fully
|
||||||
|
// masked as "***"; an empty key masks to "".
|
||||||
|
func MaskAPIKey(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(key) < 8 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return key[:4] + "…" + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryOpenCodeKeys queries OpenCode Go usage for each key sequentially and
|
||||||
|
// returns one typed result per key, in input order. A single key's failure is
|
||||||
|
// recorded only in that key's result and never aborts the loop.
|
||||||
|
func QueryOpenCodeKeys(ctx context.Context, apiBase string, keys []string, hostCallbackID string) []OpenCodeKeyResult {
|
||||||
|
results := make([]OpenCodeKeyResult, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
res, _ := queryOpenCodeKey(ctx, apiBase, key, hostCallbackID)
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
// ParseAndFormatUsage parses upstream credits JSON into structured usage metrics.
|
// ParseAndFormatUsage parses upstream credits JSON into structured usage metrics.
|
||||||
// summary (optional) carries the billing-period usage totals used to derive the monthly window.
|
// summary (optional) carries the billing-period usage totals used to derive the monthly window.
|
||||||
func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now time.Time) (*FormattedUsageResponse, error) {
|
func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now time.Time) (*FormattedUsageResponse, error) {
|
||||||
@@ -299,7 +417,7 @@ func formatMonthlyWindow(credits map[string]any, summary *UpstreamUsageSummaryRe
|
|||||||
if out.Remaining < 0 {
|
if out.Remaining < 0 {
|
||||||
out.Remaining = 0
|
out.Remaining = 0
|
||||||
}
|
}
|
||||||
out.ResetAt = "" // 账单周期重置时间上游未提供
|
out.ResetAt = "" // 账单周期重置时间上游未提供
|
||||||
out.ResetInSeconds = 0
|
out.ResetInSeconds = 0
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -376,3 +377,416 @@ func TestPlanFromWindowLimits(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseOpenCodeUsage(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
t.Run("normal payload from real upstream shape", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{
|
||||||
|
"rolling": {"status":"ok","percent":4, "resetsAt":"2026-09-17T06:58:53.171Z"},
|
||||||
|
"weekly": {"status":"ok","percent":46,"resetsAt":"2026-09-21T00:00:00.000Z"},
|
||||||
|
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
|
||||||
|
}}`)
|
||||||
|
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if !usage.OK || usage.Provider != "opencode_go" {
|
||||||
|
t.Fatalf("unexpected header: ok=%v provider=%q", usage.OK, usage.Provider)
|
||||||
|
}
|
||||||
|
if usage.UpdatedAt != "2026-09-16T12:00:00Z" {
|
||||||
|
t.Errorf("UpdatedAt = %q", usage.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
rolling := usage.Windows.Rolling
|
||||||
|
if rolling.Percent != 4 || rolling.Status != "ok" || rolling.Exceeded {
|
||||||
|
t.Errorf("rolling = %+v", rolling)
|
||||||
|
}
|
||||||
|
if rolling.ResetAt != "2026-09-17T06:58:53Z" {
|
||||||
|
t.Errorf("rolling reset_at = %q", rolling.ResetAt)
|
||||||
|
}
|
||||||
|
if rolling.ResetInSeconds != 68333 {
|
||||||
|
t.Errorf("rolling reset_in_seconds = %d, want 68333", rolling.ResetInSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
weekly := usage.Windows.Weekly
|
||||||
|
if weekly.Percent != 46 {
|
||||||
|
t.Errorf("weekly percent = %v, want 46", weekly.Percent)
|
||||||
|
}
|
||||||
|
if weekly.ResetAt != "2026-09-21T00:00:00Z" {
|
||||||
|
t.Errorf("weekly reset_at = %q, want 2026-09-21T00:00:00Z (.000Z tolerated)", weekly.ResetAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
monthly := usage.Windows.Monthly
|
||||||
|
if monthly.Percent != 23 {
|
||||||
|
t.Errorf("monthly percent = %v, want 23", monthly.Percent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("float percent", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":12.345,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if got := usage.Windows.Rolling.Percent; got != 12.35 { // Round(x*100)/100
|
||||||
|
t.Errorf("percent = %v, want 12.35", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown status tolerated", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{"rolling":{"status":"weird-status","percent":50,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if got := usage.Windows.Rolling; got.Status != "weird-status" || got.Exceeded {
|
||||||
|
t.Errorf("rolling = %+v, want status kept and not exceeded", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exceeded status", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{"rolling":{"status":"exceeded","percent":99,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if !usage.Windows.Rolling.Exceeded {
|
||||||
|
t.Error("expected Exceeded=true for status=exceeded")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("percent 100 exceeded", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":100,"resetsAt":""}}}`)
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if !usage.Windows.Rolling.Exceeded {
|
||||||
|
t.Error("expected Exceeded=true for percent=100")
|
||||||
|
}
|
||||||
|
if usage.Windows.Rolling.ResetAt != "" || usage.Windows.Rolling.ResetInSeconds != 0 {
|
||||||
|
t.Errorf("expected empty reset fields, got %+v", usage.Windows.Rolling)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("percent above 100 clamped", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":150.5,"resetsAt":""}}}`)
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if got := usage.Windows.Rolling.Percent; got != 100 {
|
||||||
|
t.Errorf("percent = %v, want 100 (clamped)", got)
|
||||||
|
}
|
||||||
|
if !usage.Windows.Rolling.Exceeded {
|
||||||
|
t.Error("expected Exceeded=true when clamped to 100")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed resetsAt not fatal", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":5,"resetsAt":"not-a-timestamp"}}}`)
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage must not fail on bad resetsAt: %v", err)
|
||||||
|
}
|
||||||
|
if got := usage.Windows.Rolling; got.ResetAt != "" || got.ResetInSeconds != 0 {
|
||||||
|
t.Errorf("expected zero reset fields on parse failure, got %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing windows tolerated as zero values", func(t *testing.T) {
|
||||||
|
raw := []byte(`{"usage":{}}`)
|
||||||
|
usage, err := ParseOpenCodeUsage(raw, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.Windows.Rolling.Percent != 0 {
|
||||||
|
t.Errorf("rolling percent = %v, want 0", usage.Windows.Rolling.Percent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty body", func(t *testing.T) {
|
||||||
|
if _, err := ParseOpenCodeUsage(nil, now); err == nil {
|
||||||
|
t.Fatal("expected error for empty body")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid JSON", func(t *testing.T) {
|
||||||
|
if _, err := ParseOpenCodeUsage([]byte(`not-json`), now); err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenCodeUsageRaw_FallbackHTTP(t *testing.T) {
|
||||||
|
var sawAuth, sawUA, sawAccept string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/usage" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sawAuth = r.Header.Get("Authorization")
|
||||||
|
sawUA = r.Header.Get("User-Agent")
|
||||||
|
sawAccept = r.Header.Get("Accept")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"usage":{"rolling":{"status":"ok","percent":4,"resetsAt":"2026-09-17T06:58:53.171Z"}}}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, status, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL, "sk-test-key", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchOpenCodeUsageRaw error: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
t.Fatal("expected non-empty body")
|
||||||
|
}
|
||||||
|
if sawAuth != "Bearer sk-test-key" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer sk-test-key", sawAuth)
|
||||||
|
}
|
||||||
|
if sawAccept != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", sawAccept)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sawUA, "cliproxy-plugin-commandcode/") {
|
||||||
|
t.Errorf("User-Agent = %q, want cliproxy-plugin-commandcode/<version>", sawUA)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, errParse := ParseOpenCodeUsage(body, time.Time{})
|
||||||
|
if errParse != nil {
|
||||||
|
t.Fatalf("ParseOpenCodeUsage error: %v", errParse)
|
||||||
|
}
|
||||||
|
if usage.Windows.Rolling.Percent != 4 {
|
||||||
|
t.Errorf("rolling percent = %v, want 4", usage.Windows.Rolling.Percent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenCodeUsageRaw_BaseTrailingSlash(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
if r.URL.Path != "/usage" {
|
||||||
|
t.Errorf("path = %q, want /usage (trailing slash trimmed)", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"usage":{"rolling":{"status":"ok","percent":1,"resetsAt":""}}}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, _, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL+"/", "sk-key", ""); err != nil {
|
||||||
|
t.Fatalf("error: %v", err)
|
||||||
|
}
|
||||||
|
if requests != 1 {
|
||||||
|
t.Fatalf("requests = %d, want 1", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenCodeUsageRaw_MissingKey(t *testing.T) {
|
||||||
|
_, status, err := FetchOpenCodeUsageRaw(context.Background(), "", "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing key")
|
||||||
|
}
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenCodeUsageRaw_EmptyKeyAfterTrim(t *testing.T) {
|
||||||
|
_, status, err := FetchOpenCodeUsageRaw(context.Background(), "", " ", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for whitespace-only key")
|
||||||
|
}
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenCodeUsageRaw_UpstreamNon200(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, status, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL, "sk-bad", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil transport error for non-200 upstream, got %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", status)
|
||||||
|
}
|
||||||
|
if string(body) != `{"error":"invalid api key"}` {
|
||||||
|
t.Errorf("body = %q", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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"}}}`)
|
||||||
|
|
||||||
|
var sawMethod, sawURL string
|
||||||
|
var sawHeaders map[string][]string
|
||||||
|
SetHostCaller(func(method string, payload []byte) ([]byte, error) {
|
||||||
|
if method != "host.http.do" {
|
||||||
|
t.Errorf("method = %s, want host.http.do", method)
|
||||||
|
}
|
||||||
|
var req HostHTTPRequest
|
||||||
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
|
t.Fatalf("unmarshal HostHTTPRequest error: %v", err)
|
||||||
|
}
|
||||||
|
sawMethod, sawURL, sawHeaders = req.Method, req.URL, req.Headers
|
||||||
|
hostResp := HostHTTPResponse{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: mockResponsePayload,
|
||||||
|
}
|
||||||
|
respJSON, _ := json.Marshal(hostResp)
|
||||||
|
return json.Marshal(Envelope{OK: true, Result: respJSON})
|
||||||
|
})
|
||||||
|
defer SetHostCaller(nil)
|
||||||
|
|
||||||
|
body, status, err := FetchOpenCodeUsageRaw(context.Background(), "https://opencode.example/v1", "sk-host-key", "cb-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchOpenCodeUsageRaw with hostCaller error: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if string(body) != string(mockResponsePayload) {
|
||||||
|
t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload))
|
||||||
|
}
|
||||||
|
if sawMethod != http.MethodGet {
|
||||||
|
t.Errorf("host request method = %s, want GET", sawMethod)
|
||||||
|
}
|
||||||
|
if sawURL != "https://opencode.example/v1/usage" {
|
||||||
|
t.Errorf("host request url = %s, want https://opencode.example/v1/usage", sawURL)
|
||||||
|
}
|
||||||
|
auth := sawHeaders["Authorization"]
|
||||||
|
if len(auth) == 0 || auth[0] != "Bearer sk-host-key" {
|
||||||
|
t.Errorf("host request Authorization = %v, want Bearer sk-host-key", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// pagecheck: syntax-check the embedded quota page JS (guards against
|
||||||
|
// parse-time SyntaxErrors like duplicate const that break the whole page).
|
||||||
|
const fs = require("fs");
|
||||||
|
const src = fs.readFileSync("plugin/quota_page.go", "utf8");
|
||||||
|
const m = src.match(/const QuotaPageHTML = `([\s\S]*)`/);
|
||||||
|
if (!m) {
|
||||||
|
console.error("pagecheck: QuotaPageHTML not found");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const js = [...m[1].matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
||||||
|
.map((x) => x[1])
|
||||||
|
.join("\n");
|
||||||
|
if (!js.trim()) {
|
||||||
|
console.error("pagecheck: no <script> content found");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new Function(js);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("pagecheck: embedded JS SyntaxError:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("pagecheck: embedded JS syntax OK");
|
||||||
Reference in New Issue
Block a user