mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 03:32:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7733552ee | ||
|
|
c2b8d89ba6 | ||
|
|
9d1884cf20 | ||
|
|
447ce65c9b | ||
|
|
4fdc9ad6c8 | ||
|
|
2f1b2fb821 | ||
|
|
3097d96a8e | ||
|
|
0a9213ec38 | ||
|
|
553226c96e | ||
|
|
61c50280b1 | ||
|
|
43c09885e5 | ||
|
|
016929f9a7 | ||
|
|
680a40ebc8 | ||
|
|
4adda19b72 | ||
|
|
dc6c2d18b3 | ||
|
|
688a7f395f |
@@ -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)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,11 +16,12 @@
|
|||||||
- [构建插件](#构建插件)
|
- [构建插件](#构建插件)
|
||||||
- [安装与目录结构](#安装与目录结构)
|
- [安装与目录结构](#安装与目录结构)
|
||||||
- [宿主配置 (`config.yaml`)](#宿主配置-configyaml)
|
- [宿主配置 (`config.yaml`)](#宿主配置-configyaml)
|
||||||
- [凭据文件配置](#凭据文件配置)
|
|
||||||
- [管理端点与资源页](#管理端点与资源页)
|
- [管理端点与资源页](#管理端点与资源页)
|
||||||
- [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)
|
||||||
- [用量数据结构说明](#用量数据结构说明)
|
- [用量数据结构说明](#用量数据结构说明)
|
||||||
- [开发与测试](#开发与测试)
|
- [开发与测试](#开发与测试)
|
||||||
- [许可证](#许可证)
|
- [许可证](#许可证)
|
||||||
@@ -32,20 +33,25 @@
|
|||||||
1. **标准 C ABI 兼容**:
|
1. **标准 C ABI 兼容**:
|
||||||
- 导出 `cliproxy_plugin_init`、`cliproxyPluginCall`、`cliproxyPluginFree`、`cliproxyPluginShutdown`。
|
- 导出 `cliproxy_plugin_init`、`cliproxyPluginCall`、`cliproxyPluginFree`、`cliproxyPluginShutdown`。
|
||||||
- 遵照 CLIProxyAPI 官方 JSON Envelope 规范(`ok`, `result`, `error`)。
|
- 遵照 CLIProxyAPI 官方 JSON Envelope 规范(`ok`, `result`, `error`)。
|
||||||
2. **双核心能力声明**:
|
2. **纯粹的管理监控能力 (`management_api`)**:
|
||||||
- `auth_provider`: 参与凭据识别、加载、解析与刷新。
|
- 注册插件自有的用量管理端点与浏览器嵌入式仪表盘资源页面。
|
||||||
- `management_api`: 注册插件自有的管理端点与浏览器资源页面。
|
- 无多余的 OAuth 提供商注册,不污染 CLIProxyAPI 后台的 OAuth 授权列表。
|
||||||
3. **凭据自动解析 (`auth.parse`)**:
|
3. **Session Token 灵活提取与支持**:
|
||||||
- 自动识别 `commandcode-*.json` 凭据文件、`type: "commandcode"` 配置或包含 `session_token` / Cookie 的凭据。
|
- 支持在 `config.yaml` 配置或在配额页面上直接输入。
|
||||||
- 提取并规范化 `__Secure-commandcode_prod_.session_token`,存入宿主持久化凭据库。
|
- 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。
|
||||||
4. **精确用量与双滑动窗口限额解析**:
|
4. **精确用量与双滑动窗口限额解析**:
|
||||||
- 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。
|
- 上游接口(v0.5.0+):配置 `commandcode_api_key`(Provider API key,长期凭据)时走 `GET https://api.commandcode.ai/alpha/billing/credits` 与 `/alpha/usage/summary`(Bearer 认证);否则回退 session cookie 查 `/internal/billing/credits`。
|
||||||
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
|
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
|
||||||
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
|
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
|
||||||
5. **嵌入式纯单文件 QuotaCard 资源页**:
|
5. **嵌入式纯单文件 QuotaCard 资源页**:
|
||||||
- 页面挂载于 `/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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,16 +61,15 @@
|
|||||||
┌────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────┐
|
||||||
│ CLIProxyAPI │
|
│ CLIProxyAPI │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────┐ ┌─────────────────────┐ │
|
│ ┌─────────────────────┐ │
|
||||||
│ │ Auth Management │ │ Management Center │ │
|
│ │ Management Center │ │
|
||||||
│ │ (reads auths/*.json) │ │ (/v0/management) │ │
|
│ │ (/v0/management) │ │
|
||||||
│ └───────────┬─────────────┘ └──────────┬──────────┘ │
|
│ └──────────┬──────────┘ │
|
||||||
│ │ C ABI │ C ABI │
|
│ │ C ABI │
|
||||||
│ ▼ ▼ │
|
│ ▼ │
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────────────────────────┐ │
|
||||||
│ │ cliproxy-plugin-commandcode.dylib/.so │ │
|
│ │ cliproxy-plugin-commandcode.dylib/.so │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ • auth.identifier / auth.parse │ │
|
|
||||||
│ │ • management.register / management.handle │ │
|
│ │ • management.register / management.handle │ │
|
||||||
│ │ • Usage Parser & Window Limits Formatter │ │
|
│ │ • Usage Parser & Window Limits Formatter │ │
|
||||||
│ │ • Embedded Single-file HTML/CSS/JS QuotaCard │ │
|
│ │ • Embedded Single-file HTML/CSS/JS QuotaCard │ │
|
||||||
@@ -79,6 +84,7 @@
|
|||||||
│ 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,34 +132,16 @@ plugins:
|
|||||||
commandcode:
|
commandcode:
|
||||||
enabled: true
|
enabled: true
|
||||||
priority: 1
|
priority: 1
|
||||||
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN"
|
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # v0.4.5 前唯一凭据;v0.5.0 起为可选回退
|
||||||
|
commandcode_api_key: "user_YOUR_COMMANDCODE_PROVIDER_KEY" # v0.5.0+ 推荐:非空则用量查询走 /alpha 端点(Bearer),无需 session cookie
|
||||||
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
|
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..."
|
||||||
```
|
```
|
||||||
|
|
||||||
### 凭据文件配置
|
|
||||||
|
|
||||||
除了在 `config.yaml` 中全局配置,你也可以在 CLIProxyAPI 的 `auths/` 凭据目录下创建凭据文件(如 `auths/commandcode-main.json`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "commandcode",
|
|
||||||
"session_token": "YOUR_COMMANDCODE_SESSION_TOKEN",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"label": "Command Code Pro"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
或者直接放入浏览器 Cookie:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "commandcode",
|
|
||||||
"cookie": "__Secure-commandcode_prod_.session_token=YOUR_COMMANDCODE_SESSION_TOKEN; Path=/;"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
插件的 `auth.parse` 会自动拦截并完成凭据加载。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 管理端点与资源页
|
## 管理端点与资源页
|
||||||
@@ -161,11 +149,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`)
|
||||||
|
|
||||||
@@ -224,6 +212,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 资源页,已同版本同步更新。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 用量数据结构说明
|
## 用量数据结构说明
|
||||||
@@ -241,6 +298,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 状态与失败原因 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
-129
@@ -1,31 +1,12 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var cookieRegex = regexp.MustCompile(`(?:^|;\s*)__Secure-commandcode_prod_\.session_token=([^;]+)`)
|
var cookieRegex = regexp.MustCompile(`(?:^|;\s*)__Secure-commandcode_prod_\.session_token=([^;]+)`)
|
||||||
|
|
||||||
// RawAuthContent represents possible structures inside a commandcode credential JSON file.
|
|
||||||
type RawAuthContent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
SessionToken string `json:"session_token"`
|
|
||||||
CommandCodeSession string `json:"commandcode_session_token"`
|
|
||||||
Cookie string `json:"cookie"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
UpstreamBase string `json:"api_base"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractSessionToken extracts the clean session token from a raw string or cookie string.
|
// ExtractSessionToken extracts the clean session token from a raw string or cookie string.
|
||||||
func ExtractSessionToken(raw string) string {
|
func ExtractSessionToken(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
@@ -54,113 +35,3 @@ func FormatSessionCookie(token string) string {
|
|||||||
return "__Secure-commandcode_prod_.session_token=" + clean
|
return "__Secure-commandcode_prod_.session_token=" + clean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseAuth handles auth.parse requests for Command Code credentials.
|
|
||||||
func ParseAuth(req AuthParseRequest) (AuthParseResponse, error) {
|
|
||||||
lowerFileName := strings.ToLower(req.FileName)
|
|
||||||
isCommandCodeFile := strings.HasPrefix(lowerFileName, "commandcode") && strings.HasSuffix(lowerFileName, ".json")
|
|
||||||
isCommandCodeProvider := strings.EqualFold(req.Provider, PluginID)
|
|
||||||
|
|
||||||
var content RawAuthContent
|
|
||||||
var rawMap map[string]any
|
|
||||||
if len(req.RawJSON) > 0 {
|
|
||||||
if err := json.Unmarshal(req.RawJSON, &content); err == nil {
|
|
||||||
_ = json.Unmarshal(req.RawJSON, &rawMap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isExplicitCommandCode := strings.EqualFold(content.Type, PluginID) ||
|
|
||||||
strings.EqualFold(content.Provider, PluginID) ||
|
|
||||||
content.SessionToken != "" ||
|
|
||||||
content.CommandCodeSession != "" ||
|
|
||||||
strings.Contains(content.Cookie, "__Secure-commandcode_prod_.session_token")
|
|
||||||
|
|
||||||
if !isCommandCodeFile && !isCommandCodeProvider && !isExplicitCommandCode {
|
|
||||||
return AuthParseResponse{Handled: false}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract session token
|
|
||||||
token := content.SessionToken
|
|
||||||
if token == "" {
|
|
||||||
token = content.CommandCodeSession
|
|
||||||
}
|
|
||||||
if token == "" && content.Cookie != "" {
|
|
||||||
token = ExtractSessionToken(content.Cookie)
|
|
||||||
}
|
|
||||||
if token == "" && (isCommandCodeFile || isCommandCodeProvider || isExplicitCommandCode) {
|
|
||||||
token = content.Token
|
|
||||||
}
|
|
||||||
token = ExtractSessionToken(token)
|
|
||||||
|
|
||||||
// Determine ID
|
|
||||||
authID := content.ID
|
|
||||||
if authID == "" && req.FileName != "" {
|
|
||||||
base := filepath.Base(req.FileName)
|
|
||||||
authID = strings.TrimSuffix(base, filepath.Ext(base))
|
|
||||||
}
|
|
||||||
if authID == "" {
|
|
||||||
authID = "commandcode-default"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine Label
|
|
||||||
label := content.Label
|
|
||||||
if label == "" {
|
|
||||||
label = content.Name
|
|
||||||
}
|
|
||||||
if label == "" && content.Email != "" {
|
|
||||||
label = fmt.Sprintf("Command Code (%s)", content.Email)
|
|
||||||
}
|
|
||||||
if label == "" {
|
|
||||||
label = fmt.Sprintf("Command Code (%s)", authID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build clean StorageJSON
|
|
||||||
storageMap := map[string]any{
|
|
||||||
"type": PluginID,
|
|
||||||
"provider": PluginID,
|
|
||||||
"session_token": token,
|
|
||||||
}
|
|
||||||
if content.Email != "" {
|
|
||||||
storageMap["email"] = content.Email
|
|
||||||
}
|
|
||||||
if content.Label != "" {
|
|
||||||
storageMap["label"] = content.Label
|
|
||||||
}
|
|
||||||
if content.UpstreamBase != "" {
|
|
||||||
storageMap["api_base"] = content.UpstreamBase
|
|
||||||
}
|
|
||||||
for k, v := range rawMap {
|
|
||||||
if _, exists := storageMap[k]; !exists {
|
|
||||||
storageMap[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
storageJSON, _ := json.Marshal(storageMap)
|
|
||||||
|
|
||||||
metadata := map[string]any{
|
|
||||||
"type": PluginID,
|
|
||||||
"session_token": token,
|
|
||||||
}
|
|
||||||
if content.Email != "" {
|
|
||||||
metadata["email"] = content.Email
|
|
||||||
}
|
|
||||||
|
|
||||||
attributes := map[string]string{
|
|
||||||
"provider": PluginID,
|
|
||||||
}
|
|
||||||
|
|
||||||
authData := AuthData{
|
|
||||||
Provider: PluginID,
|
|
||||||
ID: authID,
|
|
||||||
FileName: req.FileName,
|
|
||||||
Label: label,
|
|
||||||
Disabled: false,
|
|
||||||
StorageJSON: storageJSON,
|
|
||||||
Metadata: metadata,
|
|
||||||
Attributes: attributes,
|
|
||||||
NextRefreshAfter: time.Now().Add(24 * time.Hour).UTC(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return AuthParseResponse{
|
|
||||||
Handled: true,
|
|
||||||
Auth: authData,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,107 +59,3 @@ func TestFormatSessionCookie(t *testing.T) {
|
|||||||
t.Errorf("FormatSessionCookie() from cookie = %q, want %q", gotCookie, want)
|
t.Errorf("FormatSessionCookie() from cookie = %q, want %q", gotCookie, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAuth_ExplicitJSON(t *testing.T) {
|
|
||||||
raw := []byte(`{
|
|
||||||
"type": "commandcode",
|
|
||||||
"session_token": "test-session-token-xyz",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"label": "My Command Code Auth"
|
|
||||||
}`)
|
|
||||||
|
|
||||||
resp, err := ParseAuth(AuthParseRequest{
|
|
||||||
FileName: "custom.json",
|
|
||||||
RawJSON: raw,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseAuth error: %v", err)
|
|
||||||
}
|
|
||||||
if !resp.Handled {
|
|
||||||
t.Fatal("expected Handled=true for explicit commandcode type")
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := resp.Auth
|
|
||||||
if auth.Provider != PluginID {
|
|
||||||
t.Errorf("Provider = %q, want %q", auth.Provider, PluginID)
|
|
||||||
}
|
|
||||||
if auth.ID != "custom" {
|
|
||||||
t.Errorf("ID = %q, want %q", auth.ID, "custom")
|
|
||||||
}
|
|
||||||
if auth.Label != "My Command Code Auth" {
|
|
||||||
t.Errorf("Label = %q, want %q", auth.Label, "My Command Code Auth")
|
|
||||||
}
|
|
||||||
|
|
||||||
var storage map[string]any
|
|
||||||
if err := json.Unmarshal(auth.StorageJSON, &storage); err != nil {
|
|
||||||
t.Fatalf("failed to unmarshal StorageJSON: %v", err)
|
|
||||||
}
|
|
||||||
if storage["session_token"] != "test-session-token-xyz" {
|
|
||||||
t.Errorf("StorageJSON session_token = %v, want test-session-token-xyz", storage["session_token"])
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.Metadata["session_token"] != "test-session-token-xyz" {
|
|
||||||
t.Errorf("Metadata session_token = %v, want test-session-token-xyz", auth.Metadata["session_token"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAuth_FileNameMatch(t *testing.T) {
|
|
||||||
raw := []byte(`{
|
|
||||||
"token": "tok_987654"
|
|
||||||
}`)
|
|
||||||
|
|
||||||
resp, err := ParseAuth(AuthParseRequest{
|
|
||||||
FileName: "commandcode-work.json",
|
|
||||||
RawJSON: raw,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseAuth error: %v", err)
|
|
||||||
}
|
|
||||||
if !resp.Handled {
|
|
||||||
t.Fatal("expected Handled=true for commandcode-*.json filename")
|
|
||||||
}
|
|
||||||
if resp.Auth.ID != "commandcode-work" {
|
|
||||||
t.Errorf("ID = %q, want commandcode-work", resp.Auth.ID)
|
|
||||||
}
|
|
||||||
if resp.Auth.Metadata["session_token"] != "tok_987654" {
|
|
||||||
t.Errorf("session_token = %v, want tok_987654", resp.Auth.Metadata["session_token"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAuth_CookieFormat(t *testing.T) {
|
|
||||||
raw := []byte(`{
|
|
||||||
"cookie": "__Secure-commandcode_prod_.session_token=cookie_tok_456; Path=/"
|
|
||||||
}`)
|
|
||||||
|
|
||||||
resp, err := ParseAuth(AuthParseRequest{
|
|
||||||
FileName: "any.json",
|
|
||||||
RawJSON: raw,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseAuth error: %v", err)
|
|
||||||
}
|
|
||||||
if !resp.Handled {
|
|
||||||
t.Fatal("expected Handled=true for cookie with __Secure-commandcode_prod_.session_token")
|
|
||||||
}
|
|
||||||
if resp.Auth.Metadata["session_token"] != "cookie_tok_456" {
|
|
||||||
t.Errorf("session_token = %v, want cookie_tok_456", resp.Auth.Metadata["session_token"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAuth_UnrelatedFile(t *testing.T) {
|
|
||||||
raw := []byte(`{
|
|
||||||
"type": "openai",
|
|
||||||
"api_key": "sk-123456"
|
|
||||||
}`)
|
|
||||||
|
|
||||||
resp, err := ParseAuth(AuthParseRequest{
|
|
||||||
FileName: "openai-test.json",
|
|
||||||
RawJSON: raw,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseAuth error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Handled {
|
|
||||||
t.Fatal("expected Handled=false for unrelated credential file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+409
-13
@@ -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:
|
||||||
@@ -101,7 +157,15 @@ func handleGetUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
|
|||||||
apiBase = cfg.GetAPIBase()
|
apiBase = cfg.GetAPIBase()
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
// commandcode_api_key is NOT overridable via query parameters (same
|
||||||
|
// secrets-out-of-URLs policy as the OpenCode handler): the plugin
|
||||||
|
// config is the only credential source on GET.
|
||||||
|
apiKey := ""
|
||||||
|
if cfg != nil {
|
||||||
|
apiKey = cfg.GetCommandCodeAPIKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
|
||||||
@@ -109,6 +173,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
|||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
APIBase string `json:"api_base"`
|
APIBase string `json:"api_base"`
|
||||||
|
CommandCodeAPIKey string `json:"commandcode_api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Body) > 0 {
|
if len(req.Body) > 0 {
|
||||||
@@ -120,6 +185,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
|||||||
sessionToken = body.Token
|
sessionToken = body.Token
|
||||||
}
|
}
|
||||||
apiBase := body.APIBase
|
apiBase := body.APIBase
|
||||||
|
apiKey := body.CommandCodeAPIKey
|
||||||
|
|
||||||
// Fallback to plugin config if body didn't specify
|
// Fallback to plugin config if body didn't specify
|
||||||
if sessionToken == "" && cfg != nil {
|
if sessionToken == "" && cfg != nil {
|
||||||
@@ -128,12 +194,22 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
|
|||||||
if apiBase == "" && cfg != nil {
|
if apiBase == "" && cfg != nil {
|
||||||
apiBase = cfg.GetAPIBase()
|
apiBase = cfg.GetAPIBase()
|
||||||
}
|
}
|
||||||
|
if apiKey == "" && cfg != nil {
|
||||||
|
apiKey = cfg.GetCommandCodeAPIKey()
|
||||||
|
}
|
||||||
|
|
||||||
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
|
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackID string) (ManagementResponse, error) {
|
func executeUsageQuery(ctx context.Context, apiBase, apiKey, sessionToken, hostCallbackID string) (ManagementResponse, error) {
|
||||||
if strings.TrimSpace(sessionToken) == "" {
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
|
||||||
|
// Credential priority: commandcode_api_key non-empty → /alpha + Bearer
|
||||||
|
// (Provider API key, no cookie); otherwise session_token → /internal
|
||||||
|
// + Cookie (legacy fallback). Neither present → 400 with the
|
||||||
|
// "session_token is required" prefix (isLocalCredentialError in the
|
||||||
|
// /all aggregate depends on this message).
|
||||||
|
if strings.TrimSpace(sessionToken) == "" && apiKey == "" {
|
||||||
resBytes, _ := json.Marshal(map[string]any{
|
resBytes, _ := json.Marshal(map[string]any{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
|
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
|
||||||
@@ -147,7 +223,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, statusCode, errFetch := FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
|
var raw []byte
|
||||||
|
var statusCode int
|
||||||
|
var errFetch error
|
||||||
|
if apiKey != "" {
|
||||||
|
raw, statusCode, errFetch = FetchCommandCodeCreditsAlphaRaw(ctx, apiBase, apiKey, hostCallbackID)
|
||||||
|
} else {
|
||||||
|
raw, statusCode, errFetch = FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
|
||||||
|
}
|
||||||
if errFetch != nil {
|
if errFetch != nil {
|
||||||
resBytes, _ := json.Marshal(map[string]any{
|
resBytes, _ := json.Marshal(map[string]any{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -167,12 +250,19 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
|||||||
}
|
}
|
||||||
|
|
||||||
if statusCode != http.StatusOK {
|
if statusCode != http.StatusOK {
|
||||||
resBytes, _ := json.Marshal(map[string]any{
|
payload := map[string]any{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"status_code": statusCode,
|
"status_code": statusCode,
|
||||||
"error": "upstream returned non-200 status",
|
}
|
||||||
"body": string(raw),
|
if apiKey != "" {
|
||||||
})
|
// Alpha path: do NOT echo the upstream body; point at the
|
||||||
|
// configured Provider API key instead.
|
||||||
|
payload["error"] = fmt.Sprintf("commandcode upstream returned %d: check commandcode_api_key", statusCode)
|
||||||
|
} else {
|
||||||
|
payload["error"] = "upstream returned non-200 status"
|
||||||
|
payload["body"] = string(raw)
|
||||||
|
}
|
||||||
|
resBytes, _ := json.Marshal(payload)
|
||||||
return ManagementResponse{
|
return ManagementResponse{
|
||||||
StatusCode: statusCode,
|
StatusCode: statusCode,
|
||||||
Headers: map[string][]string{
|
Headers: map[string][]string{
|
||||||
@@ -182,7 +272,23 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
usage, errParse := ParseAndFormatUsage(raw, time.Now().UTC())
|
// Fetch billing-period (monthly) usage totals; non-fatal if unavailable.
|
||||||
|
var summary *UpstreamUsageSummaryResponse
|
||||||
|
if apiKey != "" {
|
||||||
|
if sumRaw, sumStatus, sumErr := FetchCommandCodeUsageSummaryAlphaRaw(ctx, apiBase, apiKey, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
||||||
|
var parsed UpstreamUsageSummaryResponse
|
||||||
|
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
|
||||||
|
summary = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
|
||||||
|
var parsed UpstreamUsageSummaryResponse
|
||||||
|
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
|
||||||
|
summary = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, errParse := ParseAndFormatUsage(raw, summary, time.Now().UTC())
|
||||||
if errParse != nil {
|
if errParse != nil {
|
||||||
resBytes, _ := json.Marshal(map[string]any{
|
resBytes, _ := json.Marshal(map[string]any{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -206,3 +312,293 @@ 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 := ""
|
||||||
|
commandcodeAPIKey := ""
|
||||||
|
opencodeKeys := []string{}
|
||||||
|
|
||||||
|
if req.Method == http.MethodPost && len(req.Body) > 0 {
|
||||||
|
var body struct {
|
||||||
|
SessionToken string `json:"session_token"`
|
||||||
|
CommandCodeAPIKey string `json:"commandcode_api_key"`
|
||||||
|
OpencodeAPIKeys []string `json:"opencode_api_keys"`
|
||||||
|
OpencodeAPIKey string `json:"opencode_api_key"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(req.Body, &body)
|
||||||
|
sessionToken = body.SessionToken
|
||||||
|
commandcodeAPIKey = body.CommandCodeAPIKey
|
||||||
|
opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys)
|
||||||
|
if len(opencodeKeys) == 0 {
|
||||||
|
if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" {
|
||||||
|
opencodeKeys = []string{single}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to plugin config
|
||||||
|
if sessionToken == "" && cfg != nil {
|
||||||
|
sessionToken = cfg.GetSessionToken()
|
||||||
|
}
|
||||||
|
if commandcodeAPIKey == "" && cfg != nil {
|
||||||
|
commandcodeAPIKey = cfg.GetCommandCodeAPIKey()
|
||||||
|
}
|
||||||
|
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, commandcodeAPIKey, 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"
|
||||||
|
}
|
||||||
|
|||||||
+1019
-5
File diff suppressed because it is too large
Load Diff
+113
-75
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -14,7 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
PluginID = "commandcode"
|
PluginID = "commandcode"
|
||||||
PluginName = "commandcode"
|
PluginName = "commandcode"
|
||||||
PluginVersion = "0.1.0"
|
PluginVersion = "0.5.0"
|
||||||
PluginAuthor = "zgs225"
|
PluginAuthor = "zgs225"
|
||||||
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
||||||
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
||||||
@@ -25,7 +24,11 @@ const (
|
|||||||
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"`
|
||||||
|
CommandCodeAPIKey string `yaml:"commandcode_api_key" json:"commandcode_api_key"`
|
||||||
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.
|
||||||
@@ -35,7 +38,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
|||||||
}
|
}
|
||||||
var tmp struct {
|
var tmp struct {
|
||||||
SessionToken string `yaml:"session_token"`
|
SessionToken string `yaml:"session_token"`
|
||||||
|
CommandCodeAPIKey string `yaml:"commandcode_api_key"`
|
||||||
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)
|
||||||
@@ -47,9 +54,31 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
|
|||||||
if tmp.SessionToken != "" {
|
if tmp.SessionToken != "" {
|
||||||
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
|
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
|
||||||
}
|
}
|
||||||
|
if tmp.CommandCodeAPIKey != "" {
|
||||||
|
// Provider API key is a plain Bearer token; do not run it through
|
||||||
|
// ExtractSessionToken (that is Command Code cookie specific).
|
||||||
|
c.CommandCodeAPIKey = strings.TrimSpace(tmp.CommandCodeAPIKey)
|
||||||
|
}
|
||||||
if tmp.APIBase != "" {
|
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
|
||||||
}
|
}
|
||||||
@@ -63,6 +92,15 @@ func (c *PluginConfig) GetSessionToken() string {
|
|||||||
return c.SessionToken
|
return c.SessionToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCommandCodeAPIKey safely returns the configured Command Code Provider
|
||||||
|
// API key. When non-empty, usage queries go through the /alpha endpoints
|
||||||
|
// with Bearer auth instead of the session-cookie /internal endpoints.
|
||||||
|
func (c *PluginConfig) GetCommandCodeAPIKey() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.CommandCodeAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
// SetSessionToken safely sets the session token.
|
// SetSessionToken safely sets the session token.
|
||||||
func (c *PluginConfig) SetSessionToken(token string) {
|
func (c *PluginConfig) SetSessionToken(token string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -80,6 +118,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
|
||||||
@@ -113,17 +204,6 @@ func (p *Plugin) HandleMethod(method string, requestBytes []byte) ([]byte, error
|
|||||||
case "plugin.quiesce", "plugin.shutdown":
|
case "plugin.quiesce", "plugin.shutdown":
|
||||||
return OkEnvelope(map[string]any{"shutdown": true})
|
return OkEnvelope(map[string]any{"shutdown": true})
|
||||||
|
|
||||||
case "auth.identifier":
|
|
||||||
return OkEnvelope(IdentifierResponse{Identifier: PluginID})
|
|
||||||
case "auth.parse":
|
|
||||||
return p.handleAuthParse(requestBytes)
|
|
||||||
case "auth.login.start":
|
|
||||||
return p.handleAuthLoginStart()
|
|
||||||
case "auth.login.poll":
|
|
||||||
return p.handleAuthLoginPoll()
|
|
||||||
case "auth.refresh":
|
|
||||||
return p.handleAuthRefresh(requestBytes)
|
|
||||||
|
|
||||||
case "management.register":
|
case "management.register":
|
||||||
return p.handleManagementRegister()
|
return p.handleManagementRegister()
|
||||||
case "management.handle":
|
case "management.handle":
|
||||||
@@ -155,15 +235,34 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
|
|||||||
Type: "string",
|
Type: "string",
|
||||||
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
|
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "commandcode_api_key",
|
||||||
|
Type: "string",
|
||||||
|
Description: "Command Code Provider API key (user_…); when set, usage queries go through the /alpha endpoints with Bearer auth — no session cookie needed",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "api_base",
|
Name: "api_base",
|
||||||
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{
|
||||||
AuthProvider: true,
|
|
||||||
ManagementAPI: true,
|
ManagementAPI: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -179,67 +278,6 @@ func (p *Plugin) handleReconfigure(raw []byte) ([]byte, error) {
|
|||||||
return p.handleRegister(raw)
|
return p.handleRegister(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleAuthParse(raw []byte) ([]byte, error) {
|
|
||||||
var req AuthParseRequest
|
|
||||||
if len(raw) > 0 {
|
|
||||||
if err := json.Unmarshal(raw, &req); err != nil {
|
|
||||||
return ErrorEnvelope("invalid_request", "failed to parse AuthParseRequest: "+err.Error()), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := ParseAuth(req)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorEnvelope("auth_parse_error", err.Error()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache token in config if config doesn't have one yet
|
|
||||||
if resp.Handled && resp.Auth.Metadata != nil {
|
|
||||||
if tok, ok := resp.Auth.Metadata["session_token"].(string); ok && tok != "" {
|
|
||||||
if p.config.GetSessionToken() == "" {
|
|
||||||
p.config.SetSessionToken(tok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return OkEnvelope(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) handleAuthLoginStart() ([]byte, error) {
|
|
||||||
return OkEnvelope(map[string]any{
|
|
||||||
"Provider": PluginID,
|
|
||||||
"URL": "https://commandcode.ai",
|
|
||||||
"State": "manual",
|
|
||||||
"ExpiresAt": time.Now().Add(5 * time.Minute).UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) handleAuthLoginPoll() ([]byte, error) {
|
|
||||||
return OkEnvelope(map[string]any{
|
|
||||||
"Status": "error",
|
|
||||||
"Message": "Command Code interactive login is not supported; please configure session_token or provide a commandcode-*.json credential file",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) handleAuthRefresh(raw []byte) ([]byte, error) {
|
|
||||||
var req AuthRefreshRequest
|
|
||||||
if len(raw) > 0 {
|
|
||||||
_ = json.Unmarshal(raw, &req)
|
|
||||||
}
|
|
||||||
|
|
||||||
authData := AuthData{
|
|
||||||
Provider: PluginID,
|
|
||||||
ID: req.AuthID,
|
|
||||||
StorageJSON: req.StorageJSON,
|
|
||||||
Metadata: req.Metadata,
|
|
||||||
Attributes: req.Attributes,
|
|
||||||
NextRefreshAfter: time.Now().Add(24 * time.Hour).UTC(),
|
|
||||||
}
|
|
||||||
return OkEnvelope(AuthRefreshResponse{
|
|
||||||
Auth: authData,
|
|
||||||
NextRefreshAfter: authData.NextRefreshAfter,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) handleManagementRegister() ([]byte, error) {
|
func (p *Plugin) handleManagementRegister() ([]byte, error) {
|
||||||
resp, err := RegisterManagement()
|
resp, err := RegisterManagement()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+173
-16
@@ -39,23 +39,23 @@ api_base: "https://custom-api.commandcode.ai"
|
|||||||
if reg.Metadata.Version != PluginVersion {
|
if reg.Metadata.Version != PluginVersion {
|
||||||
t.Errorf("Metadata.Version = %q, want %q", reg.Metadata.Version, PluginVersion)
|
t.Errorf("Metadata.Version = %q, want %q", reg.Metadata.Version, PluginVersion)
|
||||||
}
|
}
|
||||||
if !reg.Capabilities.AuthProvider {
|
if reg.Capabilities.AuthProvider {
|
||||||
t.Errorf("Capabilities.AuthProvider = false, want true")
|
t.Errorf("Capabilities.AuthProvider = true, want false")
|
||||||
}
|
}
|
||||||
if !reg.Capabilities.ManagementAPI {
|
if !reg.Capabilities.ManagementAPI {
|
||||||
t.Errorf("Capabilities.ManagementAPI = false, want true")
|
t.Errorf("Capabilities.ManagementAPI = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify config fields
|
// Verify config fields (v0.5.0: 5 → 6, adds commandcode_api_key)
|
||||||
if len(reg.Metadata.ConfigFields) != 2 {
|
if len(reg.Metadata.ConfigFields) != 6 {
|
||||||
t.Fatalf("ConfigFields len = %d, want 2", len(reg.Metadata.ConfigFields))
|
t.Fatalf("ConfigFields len = %d, want 6", 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["commandcode_api_key"] || !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"
|
||||||
@@ -84,7 +83,45 @@ session_token: "new-token-abc"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPluginAuthIdentifier(t *testing.T) {
|
func TestPluginConfig_CommandCodeAPIKey(t *testing.T) {
|
||||||
|
p := NewPlugin()
|
||||||
|
// Trimmed, and NOT run through ExtractSessionToken (it is a plain
|
||||||
|
// Bearer token, not a Command Code cookie string).
|
||||||
|
configYAML := []byte("commandcode_api_key: \" user_abc123xyz \"\n")
|
||||||
|
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
|
||||||
|
if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.register) error: %v", err)
|
||||||
|
}
|
||||||
|
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
|
||||||
|
t.Errorf("CommandCodeAPIKey = %q, want user_abc123xyz (trimmed, raw)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace-only value clears the field.
|
||||||
|
p2 := NewPlugin()
|
||||||
|
spaceReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("commandcode_api_key: \" \"\n")})
|
||||||
|
if _, err := p2.HandleMethod("plugin.register", spaceReq); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.register) error: %v", err)
|
||||||
|
}
|
||||||
|
if got := p2.config.GetCommandCodeAPIKey(); got != "" {
|
||||||
|
t.Errorf("CommandCodeAPIKey = %q, want empty (whitespace-only)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Omitting the key in a reconfigure must not clear a configured value.
|
||||||
|
reconfReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("session_token: \"tok\"\n")})
|
||||||
|
if _, err := p.HandleMethod("plugin.reconfigure", reconfReq); err != nil {
|
||||||
|
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
|
||||||
|
}
|
||||||
|
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
|
||||||
|
t.Errorf("CommandCodeAPIKey after reconfigure = %q, want kept user_abc123xyz", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default is empty.
|
||||||
|
if got := NewPlugin().config.GetCommandCodeAPIKey(); got != "" {
|
||||||
|
t.Errorf("default CommandCodeAPIKey = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginAuthIdentifier_NotHandled(t *testing.T) {
|
||||||
p := NewPlugin()
|
p := NewPlugin()
|
||||||
raw, err := p.HandleMethod("auth.identifier", nil)
|
raw, err := p.HandleMethod("auth.identifier", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,16 +129,14 @@ func TestPluginAuthIdentifier(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var env Envelope
|
var env Envelope
|
||||||
if err := json.Unmarshal(raw, &env); err != nil || !env.OK {
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
t.Fatalf("envelope error: %+v", env)
|
t.Fatalf("envelope error: %+v", env)
|
||||||
}
|
}
|
||||||
|
if env.OK {
|
||||||
var idResp IdentifierResponse
|
t.Fatal("expected env.OK=false for auth.identifier")
|
||||||
if err := json.Unmarshal(env.Result, &idResp); err != nil {
|
|
||||||
t.Fatalf("unmarshal idResp error: %v", err)
|
|
||||||
}
|
}
|
||||||
if idResp.Identifier != PluginID {
|
if env.Error == nil || env.Error.Code != "unknown_method" {
|
||||||
t.Errorf("Identifier = %q, want %q", idResp.Identifier, PluginID)
|
t.Errorf("Error = %+v, want code=unknown_method", env.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,3 +172,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])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+963
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThemeStorageState represents the zustand-persisted theme state from CLIProxyAPI Management Center.
|
||||||
|
type ThemeStorageState struct {
|
||||||
|
State struct {
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
ResolvedTheme string `json:"resolvedTheme"`
|
||||||
|
} `json:"state"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveThemeFromStorage extracts theme configuration from the 'cli-proxy-theme' localStorage JSON.
|
||||||
|
// Returns the user-selected theme setting ("auto", "white", "light", "dark") and the resolved theme ("dark" or "light"/"white").
|
||||||
|
func ResolveThemeFromStorage(storageJSON string) (theme string, resolved string) {
|
||||||
|
theme = "auto"
|
||||||
|
resolved = "light"
|
||||||
|
|
||||||
|
s := strings.TrimSpace(storageJSON)
|
||||||
|
if s == "" {
|
||||||
|
return theme, resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Try zustand persist format
|
||||||
|
var zustandState ThemeStorageState
|
||||||
|
if err := json.Unmarshal([]byte(s), &zustandState); err == nil && (zustandState.State.Theme != "" || zustandState.State.ResolvedTheme != "") {
|
||||||
|
if zustandState.State.Theme != "" {
|
||||||
|
theme = zustandState.State.Theme
|
||||||
|
}
|
||||||
|
if zustandState.State.ResolvedTheme != "" {
|
||||||
|
resolved = zustandState.State.ResolvedTheme
|
||||||
|
} else {
|
||||||
|
switch theme {
|
||||||
|
case "dark":
|
||||||
|
resolved = "dark"
|
||||||
|
case "white", "light":
|
||||||
|
resolved = "light"
|
||||||
|
default:
|
||||||
|
resolved = "light"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return theme, resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try simple map {"theme": "..."}
|
||||||
|
var simpleMap map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(s), &simpleMap); err == nil {
|
||||||
|
if t, ok := simpleMap["theme"].(string); ok && t != "" {
|
||||||
|
theme = t
|
||||||
|
if r, ok2 := simpleMap["resolvedTheme"].(string); ok2 && r != "" {
|
||||||
|
resolved = r
|
||||||
|
} else if theme == "dark" {
|
||||||
|
resolved = "dark"
|
||||||
|
} else {
|
||||||
|
resolved = "light"
|
||||||
|
}
|
||||||
|
return theme, resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback for raw string literals like `"dark"` or `dark`
|
||||||
|
clean := strings.ToLower(strings.Trim(s, "\" \t\r\n"))
|
||||||
|
switch clean {
|
||||||
|
case "dark":
|
||||||
|
return "dark", "dark"
|
||||||
|
case "white":
|
||||||
|
return "white", "white"
|
||||||
|
case "light":
|
||||||
|
return "light", "light"
|
||||||
|
case "auto":
|
||||||
|
return "auto", "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme, resolved
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveThemeFromStorage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
storageJSON string
|
||||||
|
wantTheme string
|
||||||
|
wantResolved string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "zustand dark theme",
|
||||||
|
storageJSON: `{"state":{"theme":"dark","resolvedTheme":"dark"},"version":0}`,
|
||||||
|
wantTheme: "dark",
|
||||||
|
wantResolved: "dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zustand white theme",
|
||||||
|
storageJSON: `{"state":{"theme":"white","resolvedTheme":"light"},"version":0}`,
|
||||||
|
wantTheme: "white",
|
||||||
|
wantResolved: "light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zustand light theme",
|
||||||
|
storageJSON: `{"state":{"theme":"light","resolvedTheme":"light"},"version":0}`,
|
||||||
|
wantTheme: "light",
|
||||||
|
wantResolved: "light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zustand auto theme with resolved dark",
|
||||||
|
storageJSON: `{"state":{"theme":"auto","resolvedTheme":"dark"},"version":0}`,
|
||||||
|
wantTheme: "auto",
|
||||||
|
wantResolved: "dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zustand auto theme with resolved light",
|
||||||
|
storageJSON: `{"state":{"theme":"auto","resolvedTheme":"light"},"version":0}`,
|
||||||
|
wantTheme: "auto",
|
||||||
|
wantResolved: "light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple json dark",
|
||||||
|
storageJSON: `{"theme":"dark"}`,
|
||||||
|
wantTheme: "dark",
|
||||||
|
wantResolved: "dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple json white",
|
||||||
|
storageJSON: `{"theme":"white"}`,
|
||||||
|
wantTheme: "white",
|
||||||
|
wantResolved: "light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "raw string dark",
|
||||||
|
storageJSON: `"dark"`,
|
||||||
|
wantTheme: "dark",
|
||||||
|
wantResolved: "dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "raw string white",
|
||||||
|
storageJSON: `white`,
|
||||||
|
wantTheme: "white",
|
||||||
|
wantResolved: "white",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string defaults to auto/light",
|
||||||
|
storageJSON: "",
|
||||||
|
wantTheme: "auto",
|
||||||
|
wantResolved: "light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid json defaults to auto/light",
|
||||||
|
storageJSON: `{not-valid-json`,
|
||||||
|
wantTheme: "auto",
|
||||||
|
wantResolved: "light",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotTheme, gotResolved := ResolveThemeFromStorage(tt.storageJSON)
|
||||||
|
if gotTheme != tt.wantTheme {
|
||||||
|
t.Errorf("theme = %q, want %q", gotTheme, tt.wantTheme)
|
||||||
|
}
|
||||||
|
if gotResolved != tt.wantResolved {
|
||||||
|
t.Errorf("resolved = %q, want %q", gotResolved, tt.wantResolved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
-53
@@ -55,59 +55,8 @@ type ConfigField struct {
|
|||||||
|
|
||||||
// RegistrationCapability declares the capabilities implemented by this plugin.
|
// RegistrationCapability declares the capabilities implemented by this plugin.
|
||||||
type RegistrationCapability struct {
|
type RegistrationCapability struct {
|
||||||
AuthProvider bool `json:"auth_provider"`
|
AuthProvider bool `json:"auth_provider,omitempty"`
|
||||||
ManagementAPI bool `json:"management_api"`
|
ManagementAPI bool `json:"management_api,omitempty"`
|
||||||
}
|
|
||||||
|
|
||||||
// IdentifierResponse is returned by auth.identifier.
|
|
||||||
type IdentifierResponse struct {
|
|
||||||
Identifier string `json:"identifier"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthData describes a credential record.
|
|
||||||
type AuthData struct {
|
|
||||||
Provider string `json:"Provider"`
|
|
||||||
ID string `json:"ID"`
|
|
||||||
FileName string `json:"FileName"`
|
|
||||||
Label string `json:"Label"`
|
|
||||||
Prefix string `json:"Prefix,omitempty"`
|
|
||||||
ProxyURL string `json:"ProxyURL,omitempty"`
|
|
||||||
Disabled bool `json:"Disabled,omitempty"`
|
|
||||||
StorageJSON []byte `json:"StorageJSON"`
|
|
||||||
Metadata map[string]any `json:"Metadata,omitempty"`
|
|
||||||
Attributes map[string]string `json:"Attributes,omitempty"`
|
|
||||||
NextRefreshAfter time.Time `json:"NextRefreshAfter,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthParseRequest is passed to auth.parse.
|
|
||||||
type AuthParseRequest struct {
|
|
||||||
Provider string `json:"Provider"`
|
|
||||||
Path string `json:"Path"`
|
|
||||||
FileName string `json:"FileName"`
|
|
||||||
RawJSON []byte `json:"RawJSON"`
|
|
||||||
Host map[string]any `json:"Host,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthParseResponse is returned by auth.parse.
|
|
||||||
type AuthParseResponse struct {
|
|
||||||
Handled bool `json:"Handled"`
|
|
||||||
Auth AuthData `json:"Auth"`
|
|
||||||
Auths []AuthData `json:"Auths,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthRefreshRequest is passed to auth.refresh.
|
|
||||||
type AuthRefreshRequest struct {
|
|
||||||
AuthID string `json:"AuthID"`
|
|
||||||
AuthProvider string `json:"AuthProvider"`
|
|
||||||
StorageJSON []byte `json:"StorageJSON"`
|
|
||||||
Metadata map[string]any `json:"Metadata,omitempty"`
|
|
||||||
Attributes map[string]string `json:"Attributes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthRefreshResponse is returned by auth.refresh.
|
|
||||||
type AuthRefreshResponse struct {
|
|
||||||
Auth AuthData `json:"Auth"`
|
|
||||||
NextRefreshAfter time.Time `json:"NextRefreshAfter,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagementRegistrationResponse is returned by management.register.
|
// ManagementRegistrationResponse is returned by management.register.
|
||||||
@@ -225,6 +174,7 @@ type UpstreamCreditsResponse struct {
|
|||||||
|
|
||||||
// UpstreamWindowLimits carries fiveHour and weekly window metrics.
|
// UpstreamWindowLimits carries fiveHour and weekly window metrics.
|
||||||
type UpstreamWindowLimits struct {
|
type UpstreamWindowLimits struct {
|
||||||
|
Limited *bool `json:"limited,omitempty"`
|
||||||
FiveHour UpstreamWindowLimit `json:"fiveHour"`
|
FiveHour UpstreamWindowLimit `json:"fiveHour"`
|
||||||
Weekly UpstreamWindowLimit `json:"weekly"`
|
Weekly UpstreamWindowLimit `json:"weekly"`
|
||||||
}
|
}
|
||||||
@@ -256,14 +206,33 @@ type UsageWindowLimitData struct {
|
|||||||
ResetInSeconds int64 `json:"reset_in_seconds"`
|
ResetInSeconds int64 `json:"reset_in_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpstreamUsageSummaryResponse reflects Command Code's /internal/usage/summary payload,
|
||||||
|
// which aggregates usage over the current billing period (monthly).
|
||||||
|
type UpstreamUsageSummaryResponse struct {
|
||||||
|
TotalCount int64 `json:"totalCount"`
|
||||||
|
TotalCost float64 `json:"totalCost"`
|
||||||
|
TotalCredits float64 `json:"totalCredits"`
|
||||||
|
TotalMonthlyCredits float64 `json:"totalMonthlyCredits"`
|
||||||
|
TotalPurchasedCredits float64 `json:"totalPurchasedCredits"`
|
||||||
|
PeriodBasis string `json:"periodBasis"`
|
||||||
|
}
|
||||||
|
|
||||||
// UsageWindowLimitsData contains both windows.
|
// UsageWindowLimitsData contains both windows.
|
||||||
type UsageWindowLimitsData struct {
|
type UsageWindowLimitsData struct {
|
||||||
|
Monthly UsageWindowLimitData `json:"monthly"`
|
||||||
FiveHour UsageWindowLimitData `json:"five_hour"`
|
FiveHour UsageWindowLimitData `json:"five_hour"`
|
||||||
Weekly UsageWindowLimitData `json:"weekly"`
|
Weekly UsageWindowLimitData `json:"weekly"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlanInfo represents inferred Command Code subscription plan details.
|
||||||
|
type PlanInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
// FormattedUsageData is the complete formatted usage payload.
|
// FormattedUsageData is the complete formatted usage payload.
|
||||||
type FormattedUsageData struct {
|
type FormattedUsageData struct {
|
||||||
|
Plan PlanInfo `json:"plan"`
|
||||||
Credits UsageCreditsData `json:"credits"`
|
Credits UsageCreditsData `json:"credits"`
|
||||||
WindowLimits UsageWindowLimitsData `json:"window_limits"`
|
WindowLimits UsageWindowLimitsData `json:"window_limits"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -272,9 +241,92 @@ type FormattedUsageData struct {
|
|||||||
// FormattedUsageResponse is returned by GET /plugins/commandcode/usage and POST /plugins/commandcode/usage.
|
// FormattedUsageResponse is returned by GET /plugins/commandcode/usage and POST /plugins/commandcode/usage.
|
||||||
type FormattedUsageResponse struct {
|
type FormattedUsageResponse struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
|
Plan PlanInfo `json:"plan"`
|
||||||
Data FormattedUsageData `json:"data"`
|
Data FormattedUsageData `json:"data"`
|
||||||
Credits UsageCreditsData `json:"credits"`
|
Credits UsageCreditsData `json:"credits"`
|
||||||
WindowLimits UsageWindowLimitsData `json:"window_limits"`
|
WindowLimits UsageWindowLimitsData `json:"window_limits"`
|
||||||
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"`
|
||||||
|
}
|
||||||
|
|||||||
+283
-14
@@ -15,6 +15,7 @@ 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.
|
||||||
@@ -42,8 +43,9 @@ func SetDefaultHTTPClient(client HTTPDoer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchCreditsRaw fetches raw upstream credit data via host.http.do or net/http fallback.
|
// fetchUpstream performs a GET on an internal Command Code endpoint, reusing
|
||||||
func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCallbackID string) ([]byte, int, error) {
|
// the host.http.do bridge when available, else falling back to net/http.
|
||||||
|
func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCallbackID string) ([]byte, int, error) {
|
||||||
cleanToken := ExtractSessionToken(sessionToken)
|
cleanToken := ExtractSessionToken(sessionToken)
|
||||||
if cleanToken == "" {
|
if cleanToken == "" {
|
||||||
return nil, http.StatusBadRequest, errors.New("missing session_token: please provide a valid Command Code session token")
|
return nil, http.StatusBadRequest, errors.New("missing session_token: please provide a valid Command Code session token")
|
||||||
@@ -52,19 +54,27 @@ func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCall
|
|||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = DefaultAPIBase
|
apiBase = DefaultAPIBase
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/internal/billing/credits", strings.TrimRight(apiBase, "/"))
|
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": {"cliproxy-plugin-commandcode/0.1.0"},
|
|
||||||
},
|
|
||||||
HostCallbackID: hostCallbackID,
|
HostCallbackID: hostCallbackID,
|
||||||
}
|
}
|
||||||
rawReq, errMarshal := json.Marshal(reqPayload)
|
rawReq, errMarshal := json.Marshal(reqPayload)
|
||||||
@@ -96,13 +106,15 @@ func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCall
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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", "cliproxy-plugin-commandcode/0.1.0")
|
httpReq.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res, errDo := defaultHTTPClient.Do(httpReq)
|
res, errDo := defaultHTTPClient.Do(httpReq)
|
||||||
if errDo != nil {
|
if errDo != nil {
|
||||||
@@ -120,8 +132,161 @@ func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCall
|
|||||||
return body, res.StatusCode, nil
|
return body, res.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchCreditsRaw fetches raw upstream credit data via host.http.do or net/http fallback.
|
||||||
|
func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
return fetchUpstream(ctx, apiBase, "internal/billing/credits", sessionToken, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchUsageSummaryRaw fetches the billing-period (monthly) usage totals.
|
||||||
|
func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchUpstreamAlpha performs a GET on a Command Code /alpha endpoint using a
|
||||||
|
// Provider API key (Bearer auth) instead of a session cookie, reusing the
|
||||||
|
// host.http.do bridge when available, else falling back to net/http.
|
||||||
|
func fetchUpstreamAlpha(ctx context.Context, apiBase, endpoint, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, http.StatusBadRequest, errors.New("missing commandcode_api_key: please provide a valid Command Code Provider API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = DefaultAPIBase
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
|
||||||
|
|
||||||
|
headers := map[string][]string{
|
||||||
|
"Authorization": {"Bearer " + apiKey},
|
||||||
|
"Accept": {"application/json"},
|
||||||
|
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
|
||||||
|
}
|
||||||
|
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCommandCodeCreditsAlphaRaw fetches raw credit data from
|
||||||
|
// {apiBase}/alpha/billing/credits with Bearer auth (Provider API key),
|
||||||
|
// via host.http.do or net/http fallback.
|
||||||
|
func FetchCommandCodeCreditsAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
return fetchUpstreamAlpha(ctx, apiBase, "alpha/billing/credits", apiKey, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCommandCodeUsageSummaryAlphaRaw fetches the billing-period (monthly)
|
||||||
|
// usage totals from {apiBase}/alpha/usage/summary with Bearer auth.
|
||||||
|
func FetchCommandCodeUsageSummaryAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
return fetchUpstreamAlpha(ctx, apiBase, "alpha/usage/summary", apiKey, hostCallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
|
||||||
|
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
|
||||||
|
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
|
||||||
|
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.
|
||||||
func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, error) {
|
// 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) {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return nil, errors.New("empty response body from upstream")
|
return nil, errors.New("empty response body from upstream")
|
||||||
}
|
}
|
||||||
@@ -149,9 +314,14 @@ func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, er
|
|||||||
|
|
||||||
// Format window limits
|
// Format window limits
|
||||||
windowLimitsData := formatWindowLimits(upstream.WindowLimits, now)
|
windowLimitsData := formatWindowLimits(upstream.WindowLimits, now)
|
||||||
|
windowLimitsData.Monthly = formatMonthlyWindow(upstream.Credits, summary, now)
|
||||||
|
|
||||||
|
// Inferred subscription plan
|
||||||
|
planInfo := PlanFromWindowLimits(upstream.WindowLimits.FiveHour.Cap, upstream.WindowLimits.Weekly.Cap, upstream.WindowLimits.Limited)
|
||||||
|
|
||||||
nowRFC := now.Format(time.RFC3339)
|
nowRFC := now.Format(time.RFC3339)
|
||||||
data := FormattedUsageData{
|
data := FormattedUsageData{
|
||||||
|
Plan: planInfo,
|
||||||
Credits: creditsData,
|
Credits: creditsData,
|
||||||
WindowLimits: windowLimitsData,
|
WindowLimits: windowLimitsData,
|
||||||
UpdatedAt: nowRFC,
|
UpdatedAt: nowRFC,
|
||||||
@@ -159,6 +329,7 @@ func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, er
|
|||||||
|
|
||||||
return &FormattedUsageResponse{
|
return &FormattedUsageResponse{
|
||||||
OK: true,
|
OK: true,
|
||||||
|
Plan: planInfo,
|
||||||
Data: data,
|
Data: data,
|
||||||
Credits: creditsData,
|
Credits: creditsData,
|
||||||
WindowLimits: windowLimitsData,
|
WindowLimits: windowLimitsData,
|
||||||
@@ -166,6 +337,69 @@ func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlanFromWindowLimits infers the Command Code subscription plan based on window limit caps.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - windowLimits.limited == false -> Provider plan (pay-as-you-go)
|
||||||
|
// - 5h cap=14 && weekly cap=35 -> GOAT plan
|
||||||
|
// - 5h cap=16 && weekly cap=40 -> Pro plan
|
||||||
|
// - 5h cap=90 && weekly cap=180 -> Max 20× plan
|
||||||
|
// - 5h cap=45 && weekly cap=90 -> Max 10× plan
|
||||||
|
// - 5h cap=3 && weekly cap=6 -> Go plan
|
||||||
|
// - Otherwise -> Unknown
|
||||||
|
func PlanFromWindowLimits(fiveHourCap, weeklyCap float64, limited *bool) PlanInfo {
|
||||||
|
if limited != nil && !*limited {
|
||||||
|
return PlanInfo{
|
||||||
|
Name: "Provider",
|
||||||
|
Code: "provider",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match := func(capVal, target float64) bool {
|
||||||
|
return math.Abs(capVal-target) < 0.01
|
||||||
|
}
|
||||||
|
|
||||||
|
if match(fiveHourCap, 14) && match(weeklyCap, 35) {
|
||||||
|
return PlanInfo{
|
||||||
|
Name: "GOAT",
|
||||||
|
Code: "goat",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if match(fiveHourCap, 16) && match(weeklyCap, 40) {
|
||||||
|
return PlanInfo{
|
||||||
|
Name: "Pro",
|
||||||
|
Code: "pro",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if match(fiveHourCap, 90) && match(weeklyCap, 180) {
|
||||||
|
return PlanInfo{
|
||||||
|
Name: "Max 20×",
|
||||||
|
Code: "max_20x",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if match(fiveHourCap, 45) && match(weeklyCap, 90) {
|
||||||
|
return PlanInfo{
|
||||||
|
Name: "Max 10×",
|
||||||
|
Code: "max_10x",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if match(fiveHourCap, 3) && match(weeklyCap, 6) {
|
||||||
|
return PlanInfo{
|
||||||
|
Name: "Go",
|
||||||
|
Code: "go",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlanInfo{
|
||||||
|
Name: "Unknown",
|
||||||
|
Code: "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func formatCredits(credits map[string]any) UsageCreditsData {
|
func formatCredits(credits map[string]any) UsageCreditsData {
|
||||||
data := UsageCreditsData{
|
data := UsageCreditsData{
|
||||||
Details: credits,
|
Details: credits,
|
||||||
@@ -188,6 +422,41 @@ func formatWindowLimits(upstream UpstreamWindowLimits, now time.Time) UsageWindo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatMonthlyWindow derives the monthly (billing period) window from the
|
||||||
|
// billing/credits response (remaining monthlyCredits) and the
|
||||||
|
// /internal/usage/summary response (totalMonthlyCredits consumed this period).
|
||||||
|
// cap = consumed + remaining, used = consumed, remaining = monthlyCredits.
|
||||||
|
// Returns a zero window when the summary (consumed totals) is unavailable,
|
||||||
|
// because a monthly window cannot be derived from remaining credits alone.
|
||||||
|
func formatMonthlyWindow(credits map[string]any, summary *UpstreamUsageSummaryResponse, now time.Time) UsageWindowLimitData {
|
||||||
|
out := UsageWindowLimitData{}
|
||||||
|
if summary == nil || summary.TotalMonthlyCredits <= 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := getFloatFromMap(credits, "monthlyCredits", "monthly_credits")
|
||||||
|
used := summary.TotalMonthlyCredits
|
||||||
|
|
||||||
|
capTotal := used + remaining
|
||||||
|
if capTotal > 0 {
|
||||||
|
percentage := (used / capTotal) * 100.0
|
||||||
|
if percentage > 100.0 {
|
||||||
|
percentage = 100.0
|
||||||
|
}
|
||||||
|
out.Percentage = math.Round(percentage*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Used = used
|
||||||
|
out.Cap = capTotal
|
||||||
|
out.Remaining = remaining
|
||||||
|
if out.Remaining < 0 {
|
||||||
|
out.Remaining = 0
|
||||||
|
}
|
||||||
|
out.ResetAt = "" // 账单周期重置时间上游未提供
|
||||||
|
out.ResetInSeconds = 0
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func formatSingleWindow(w UpstreamWindowLimit, now time.Time) UsageWindowLimitData {
|
func formatSingleWindow(w UpstreamWindowLimit, now time.Time) UsageWindowLimitData {
|
||||||
var remaining float64
|
var remaining float64
|
||||||
var percentage float64
|
var percentage float64
|
||||||
|
|||||||
+697
-2
@@ -3,8 +3,10 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -81,7 +83,44 @@ func TestParseAndFormatUsage(t *testing.T) {
|
|||||||
}`)
|
}`)
|
||||||
|
|
||||||
now := time.Date(2025, 3, 4, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2025, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||||
usage, err := ParseAndFormatUsage(raw, now)
|
|
||||||
|
t.Run("without summary monthly defaults empty", func(t *testing.T) {
|
||||||
|
usage, err := ParseAndFormatUsage(raw, nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAndFormatUsage error: %v", err)
|
||||||
|
}
|
||||||
|
if !usage.OK {
|
||||||
|
t.Fatal("expected OK=true")
|
||||||
|
}
|
||||||
|
if usage.WindowLimits.Monthly.Used != 0 || usage.WindowLimits.Monthly.Cap != 0 {
|
||||||
|
t.Errorf("expected empty monthly without summary, got %+v", usage.WindowLimits.Monthly)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("monthly window derived from summary + remaining credits", func(t *testing.T) {
|
||||||
|
summary := &UpstreamUsageSummaryResponse{TotalMonthlyCredits: 30.0}
|
||||||
|
// monthlyCredits in raw = 1000 remaining, so cap = 1030
|
||||||
|
usage, err := ParseAndFormatUsage(raw, summary, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAndFormatUsage error: %v", err)
|
||||||
|
}
|
||||||
|
m := usage.WindowLimits.Monthly
|
||||||
|
if m.Used != 30.0 {
|
||||||
|
t.Errorf("monthly used = %v, want 30", m.Used)
|
||||||
|
}
|
||||||
|
if m.Cap != 1030.0 {
|
||||||
|
t.Errorf("monthly cap = %v, want 1030", m.Cap)
|
||||||
|
}
|
||||||
|
if m.Remaining != 1000.0 {
|
||||||
|
t.Errorf("monthly remaining = %v, want 1000", m.Remaining)
|
||||||
|
}
|
||||||
|
wantPct := math.Round((30.0/1030.0)*10000) / 100
|
||||||
|
if m.Percentage != wantPct {
|
||||||
|
t.Errorf("monthly percentage = %v, want %v", m.Percentage, wantPct)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
usage, err := ParseAndFormatUsage(raw, nil, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseAndFormatUsage error: %v", err)
|
t.Fatalf("ParseAndFormatUsage error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -136,6 +175,27 @@ func TestParseAndFormatUsage(t *testing.T) {
|
|||||||
if weekly.ResetAt != "2025-03-10T12:00:00Z" {
|
if weekly.ResetAt != "2025-03-10T12:00:00Z" {
|
||||||
t.Errorf("Weekly ResetAt = %v, want 2025-03-10T12:00:00Z", weekly.ResetAt)
|
t.Errorf("Weekly ResetAt = %v, want 2025-03-10T12:00:00Z", weekly.ResetAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify inferred plan (cap 25 / 100 is unknown)
|
||||||
|
if usage.Plan.Name != "Unknown" || usage.Plan.Code != "unknown" {
|
||||||
|
t.Errorf("Plan = %+v, want Unknown", usage.Plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify plan inference for GOAT
|
||||||
|
rawGOAT := []byte(`{
|
||||||
|
"credits": {"monthlyCredits": 100},
|
||||||
|
"windowLimits": {
|
||||||
|
"fiveHour": {"used": 2, "cap": 14},
|
||||||
|
"weekly": {"used": 10, "cap": 35}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
usageGOAT, errGOAT := ParseAndFormatUsage(rawGOAT, nil, now)
|
||||||
|
if errGOAT != nil {
|
||||||
|
t.Fatalf("ParseAndFormatUsage GOAT error: %v", errGOAT)
|
||||||
|
}
|
||||||
|
if usageGOAT.Plan.Name != "GOAT" || usageGOAT.Plan.Code != "goat" {
|
||||||
|
t.Errorf("GOAT Plan = %+v, want name=GOAT code=goat", usageGOAT.Plan)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) {
|
func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) {
|
||||||
@@ -174,7 +234,7 @@ func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) {
|
|||||||
t.Fatal("expected non-empty body")
|
t.Fatal("expected non-empty body")
|
||||||
}
|
}
|
||||||
|
|
||||||
usage, errParse := ParseAndFormatUsage(body, time.Time{})
|
usage, errParse := ParseAndFormatUsage(body, nil, time.Time{})
|
||||||
if errParse != nil {
|
if errParse != nil {
|
||||||
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
|
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
|
||||||
}
|
}
|
||||||
@@ -220,3 +280,638 @@ func TestFetchCreditsRaw_HostCaller(t *testing.T) {
|
|||||||
t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload))
|
t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlanFromWindowLimits(t *testing.T) {
|
||||||
|
trueVal := true
|
||||||
|
falseVal := false
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fiveHourCap float64
|
||||||
|
weeklyCap float64
|
||||||
|
limited *bool
|
||||||
|
wantName string
|
||||||
|
wantCode string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GOAT plan",
|
||||||
|
fiveHourCap: 14,
|
||||||
|
weeklyCap: 35,
|
||||||
|
limited: &trueVal,
|
||||||
|
wantName: "GOAT",
|
||||||
|
wantCode: "goat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pro plan",
|
||||||
|
fiveHourCap: 16,
|
||||||
|
weeklyCap: 40,
|
||||||
|
limited: nil,
|
||||||
|
wantName: "Pro",
|
||||||
|
wantCode: "pro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Max 20x plan",
|
||||||
|
fiveHourCap: 90,
|
||||||
|
weeklyCap: 180,
|
||||||
|
limited: &trueVal,
|
||||||
|
wantName: "Max 20×",
|
||||||
|
wantCode: "max_20x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Max 10x plan",
|
||||||
|
fiveHourCap: 45,
|
||||||
|
weeklyCap: 90,
|
||||||
|
limited: nil,
|
||||||
|
wantName: "Max 10×",
|
||||||
|
wantCode: "max_10x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Go plan",
|
||||||
|
fiveHourCap: 3,
|
||||||
|
weeklyCap: 6,
|
||||||
|
limited: nil,
|
||||||
|
wantName: "Go",
|
||||||
|
wantCode: "go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Provider pay-as-you-go plan",
|
||||||
|
fiveHourCap: 0,
|
||||||
|
weeklyCap: 0,
|
||||||
|
limited: &falseVal,
|
||||||
|
wantName: "Provider",
|
||||||
|
wantCode: "provider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Provider plan with caps set but limited=false",
|
||||||
|
fiveHourCap: 14,
|
||||||
|
weeklyCap: 35,
|
||||||
|
limited: &falseVal,
|
||||||
|
wantName: "Provider",
|
||||||
|
wantCode: "provider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Float tolerance test",
|
||||||
|
fiveHourCap: 13.999,
|
||||||
|
weeklyCap: 35.001,
|
||||||
|
limited: nil,
|
||||||
|
wantName: "GOAT",
|
||||||
|
wantCode: "goat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unknown caps",
|
||||||
|
fiveHourCap: 10,
|
||||||
|
weeklyCap: 20,
|
||||||
|
limited: nil,
|
||||||
|
wantName: "Unknown",
|
||||||
|
wantCode: "unknown",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := PlanFromWindowLimits(tt.fiveHourCap, tt.weeklyCap, tt.limited)
|
||||||
|
if got.Name != tt.wantName || got.Code != tt.wantCode {
|
||||||
|
t.Errorf("PlanFromWindowLimits(%v, %v, %v) = %+v, want name=%q code=%q",
|
||||||
|
tt.fiveHourCap, tt.weeklyCap, tt.limited, got, tt.wantName, tt.wantCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.5.0: the /alpha endpoints authenticate with a Bearer Provider API key
|
||||||
|
// instead of the session cookie. The URL must be /alpha/billing/credits and
|
||||||
|
// no Cookie header may be sent.
|
||||||
|
func TestFetchCommandCodeCreditsAlphaRaw_FallbackHTTP(t *testing.T) {
|
||||||
|
var sawAuth, sawCookie, sawAccept, sawUA string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/alpha/billing/credits" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sawAuth = r.Header.Get("Authorization")
|
||||||
|
sawCookie = r.Header.Get("Cookie")
|
||||||
|
sawAccept = r.Header.Get("Accept")
|
||||||
|
sawUA = r.Header.Get("User-Agent")
|
||||||
|
|
||||||
|
// Alpha credits omit opensourceMonthlyCredits (field difference vs
|
||||||
|
// the internal endpoint); formatCredits must tolerate that.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":700},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), ts.URL, "user_test-key", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchCommandCodeCreditsAlphaRaw error: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if sawAuth != "Bearer user_test-key" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
|
||||||
|
}
|
||||||
|
if sawCookie != "" {
|
||||||
|
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
|
||||||
|
}
|
||||||
|
if sawAccept != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", sawAccept)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sawUA, "cliproxy-plugin-commandcode/") {
|
||||||
|
t.Errorf("User-Agent = %q, want cliproxy-plugin-commandcode/<version>", sawUA)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, errParse := ParseAndFormatUsage(body, nil, time.Time{})
|
||||||
|
if errParse != nil {
|
||||||
|
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
|
||||||
|
}
|
||||||
|
if usage.Credits.MonthlyCredits != 700 {
|
||||||
|
t.Errorf("MonthlyCredits = %v, want 700", usage.Credits.MonthlyCredits)
|
||||||
|
}
|
||||||
|
if usage.Credits.OpensourceMonthlyCredits != 0 {
|
||||||
|
t.Errorf("OpensourceMonthlyCredits = %v, want 0 (field absent in alpha payload)", usage.Credits.OpensourceMonthlyCredits)
|
||||||
|
}
|
||||||
|
// total = monthly + 0 when opensourceMonthlyCredits is missing.
|
||||||
|
if usage.Credits.TotalCredits != 700 {
|
||||||
|
t.Errorf("TotalCredits = %v, want 700 (= monthly when opensource field absent)", usage.Credits.TotalCredits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCommandCodeUsageSummaryAlphaRaw_FallbackHTTP(t *testing.T) {
|
||||||
|
var sawAuth, sawCookie string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/alpha/usage/summary" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sawAuth = r.Header.Get("Authorization")
|
||||||
|
sawCookie = r.Header.Get("Cookie")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 123}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
SetHostCaller(nil)
|
||||||
|
SetDefaultHTTPClient(ts.Client())
|
||||||
|
defer func() {
|
||||||
|
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, status, err := FetchCommandCodeUsageSummaryAlphaRaw(context.Background(), ts.URL+"/", "user_test-key", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchCommandCodeUsageSummaryAlphaRaw error: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if sawAuth != "Bearer user_test-key" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
|
||||||
|
}
|
||||||
|
if sawCookie != "" {
|
||||||
|
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary UpstreamUsageSummaryResponse
|
||||||
|
if err := json.Unmarshal(body, &summary); err != nil {
|
||||||
|
t.Fatalf("unmarshal summary error: %v", err)
|
||||||
|
}
|
||||||
|
if summary.TotalMonthlyCredits != 123 {
|
||||||
|
t.Errorf("TotalMonthlyCredits = %v, want 123", summary.TotalMonthlyCredits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCommandCodeCreditsAlphaRaw_MissingKey(t *testing.T) {
|
||||||
|
SetHostCaller(nil)
|
||||||
|
for _, key := range []string{"", " "} {
|
||||||
|
_, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), "", key, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("key %q: expected error for missing key", key)
|
||||||
|
}
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Errorf("key %q: status = %d, want 400", key, status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing commandcode_api_key") {
|
||||||
|
t.Errorf("key %q: error = %q, want it to mention missing commandcode_api_key", key, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// pagecheck: syntax-check the embedded quota page JS (guards against
|
||||||
|
// parse-time SyntaxErrors like duplicate const that break the whole page).
|
||||||
|
const fs = require("fs");
|
||||||
|
const src = fs.readFileSync("plugin/quota_page.go", "utf8");
|
||||||
|
const m = src.match(/const QuotaPageHTML = `([\s\S]*)`/);
|
||||||
|
if (!m) {
|
||||||
|
console.error("pagecheck: QuotaPageHTML not found");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const js = [...m[1].matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
||||||
|
.map((x) => x[1])
|
||||||
|
.join("\n");
|
||||||
|
if (!js.trim()) {
|
||||||
|
console.error("pagecheck: no <script> content found");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new Function(js);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("pagecheck: embedded JS SyntaxError:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("pagecheck: embedded JS syntax OK");
|
||||||
|
|
||||||
|
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
|
||||||
|
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
|
||||||
|
// updateTimers() that was never declared and only existed as a global
|
||||||
|
// property accidentally created by renderUsage()).
|
||||||
|
//
|
||||||
|
// Approach (deliberately simple/grep-style, no DOM execution):
|
||||||
|
// 1. strip comments and string literals
|
||||||
|
// 2. collect every var/let/const/function declaration name + function/
|
||||||
|
// callback/catch parameter
|
||||||
|
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
|
||||||
|
// bare if()/while() condition identifiers, and for-loop init identifiers
|
||||||
|
// 4. whitelist known globals; anything left is reported and fails the check
|
||||||
|
|
||||||
|
const whitelist = new Set([
|
||||||
|
// browser builtins referenced by the page
|
||||||
|
"document", "window", "localStorage", "sessionStorage", "fetch",
|
||||||
|
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
|
||||||
|
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
|
||||||
|
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
|
||||||
|
"escape", "unescape", "navigator", "location", "history", "URL",
|
||||||
|
"URLSearchParams", "FormData", "Headers", "Request", "Response",
|
||||||
|
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
|
||||||
|
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function stripLiterals(source) {
|
||||||
|
// Remove comments, string literals, and regex literals; replace with
|
||||||
|
// harmless placeholders so identifier scanning never sees string content.
|
||||||
|
// Template literals are NOT fully discarded: their ${...} interpolations
|
||||||
|
// are kept as "( ... )" so identifiers inside them stay visible.
|
||||||
|
//
|
||||||
|
// A "/" only starts a regex literal when it appears in expression
|
||||||
|
// position (previous significant token is an operator/open bracket or a
|
||||||
|
// keyword such as return/typeof). Otherwise it is division.
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
const n = source.length;
|
||||||
|
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
|
||||||
|
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
|
||||||
|
const stack = [{ type: "expr", depth: Infinity }];
|
||||||
|
// Last significant (non-whitespace) emitted char + last identifier word,
|
||||||
|
// used for the regex-vs-division heuristic.
|
||||||
|
let lastSig = "";
|
||||||
|
let lastWord = "";
|
||||||
|
const KEYWORDS_BEFORE_REGEX = new Set([
|
||||||
|
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
|
||||||
|
"do", "else", "case", "throw", "await", "yield",
|
||||||
|
]);
|
||||||
|
const emit = (text) => {
|
||||||
|
for (const ch of text) {
|
||||||
|
if (/\s/.test(ch)) continue;
|
||||||
|
if (/[A-Za-z0-9_$]/.test(ch)) {
|
||||||
|
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
|
||||||
|
} else {
|
||||||
|
lastWord = "";
|
||||||
|
}
|
||||||
|
lastSig = ch;
|
||||||
|
}
|
||||||
|
out += text;
|
||||||
|
};
|
||||||
|
const regexAllowed = () =>
|
||||||
|
lastSig === "" ||
|
||||||
|
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
|
||||||
|
KEYWORDS_BEFORE_REGEX.has(lastWord);
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ctx = stack[stack.length - 1];
|
||||||
|
const c = source[i];
|
||||||
|
const next = source[i + 1];
|
||||||
|
|
||||||
|
if (ctx.type === "tmpl") {
|
||||||
|
// Inside template literal text: skip until ` or ${ ... }
|
||||||
|
if (c === "\\") { i += 2; continue; }
|
||||||
|
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
|
||||||
|
if (c === "$" && next === "{") {
|
||||||
|
i += 2;
|
||||||
|
stack.push({ type: "expr", depth: 0 });
|
||||||
|
emit(" ( ");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// code context (top-level or template ${...} expression)
|
||||||
|
if (c === "/" && next === "/") {
|
||||||
|
while (i < n && source[i] !== "\n") i++;
|
||||||
|
} else if (c === "/" && next === "*") {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
||||||
|
i += 2;
|
||||||
|
} else if (c === "/" && regexAllowed()) {
|
||||||
|
// regex literal: skip to unescaped closing / (not inside [...])
|
||||||
|
i++;
|
||||||
|
let inClass = false;
|
||||||
|
while (i < n) {
|
||||||
|
if (source[i] === "\\") { i += 2; continue; }
|
||||||
|
if (source[i] === "[") { inClass = true; i++; continue; }
|
||||||
|
if (source[i] === "]") { inClass = false; i++; continue; }
|
||||||
|
if (source[i] === "/" && !inClass) { i++; break; }
|
||||||
|
if (source[i] === "\n") break; // malformed; bail out safely
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
|
||||||
|
emit(" / ");
|
||||||
|
} else if (c === '"' || c === "'") {
|
||||||
|
const quote = c;
|
||||||
|
i++;
|
||||||
|
while (i < n) {
|
||||||
|
if (source[i] === "\\") { i += 2; continue; }
|
||||||
|
if (source[i] === quote) { i++; break; }
|
||||||
|
if (source[i] === "\n") break;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
emit(" " + quote + quote + " ");
|
||||||
|
} else if (c === "`") {
|
||||||
|
i++;
|
||||||
|
stack.push({ type: "tmpl" });
|
||||||
|
emit(" ");
|
||||||
|
} else {
|
||||||
|
if (c === "{") ctx.depth++;
|
||||||
|
if (c === "}") {
|
||||||
|
if (ctx.depth === 0) {
|
||||||
|
// closes a template interpolation: back into template text
|
||||||
|
stack.pop();
|
||||||
|
i++;
|
||||||
|
emit(" ) ");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.depth--;
|
||||||
|
}
|
||||||
|
emit(c);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDeclarations(clean) {
|
||||||
|
const declared = new Set();
|
||||||
|
const addParamList = (raw) => {
|
||||||
|
for (const p of raw.split(",")) {
|
||||||
|
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
|
||||||
|
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
|
||||||
|
declared.add(m[1]);
|
||||||
|
// function declarations/expressions: name + params
|
||||||
|
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
|
||||||
|
if (m[1]) declared.add(m[1]);
|
||||||
|
addParamList(m[2]);
|
||||||
|
}
|
||||||
|
// arrow functions: (a, b) => and a =>
|
||||||
|
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
|
||||||
|
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
|
||||||
|
// catch (e) and destructuring catch
|
||||||
|
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = stripLiterals(js);
|
||||||
|
const declared = collectDeclarations(clean);
|
||||||
|
const flagged = new Set();
|
||||||
|
|
||||||
|
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
|
||||||
|
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
|
||||||
|
const name = m[1];
|
||||||
|
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||||
|
}
|
||||||
|
// 2. bare if()/while() condition identifiers
|
||||||
|
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
|
||||||
|
const name = m[2];
|
||||||
|
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||||
|
}
|
||||||
|
// 3. for-loop init without let/var: for (i = 0; ...)
|
||||||
|
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
|
||||||
|
const name = m[1];
|
||||||
|
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flagged.size > 0) {
|
||||||
|
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
|
||||||
|
for (const name of [...flagged].sort()) console.error(" - " + name);
|
||||||
|
console.error(
|
||||||
|
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
|
||||||
|
"if this is a false positive, extend scripts/pagecheck.js"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user