feat: initial implementation of CLIProxyAPI Command Code plugin (stage 1)

This commit is contained in:
2026-09-04 10:39:04 +08:00
commit 4ce221c22a
18 changed files with 3409 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Binaries & Shared Libraries
*.dylib
*.so
*.dll
*.h
*.exe
*.test
# Go workspace & coverage
coverage.txt
*.out
# OS & IDE
.DS_Store
.idea/
.vscode/
*.swp
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 yuez (zgs225)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
TARGET := commandcode.dylib
else ifeq ($(OS),Windows_NT)
TARGET := commandcode.dll
else
TARGET := commandcode.so
endif
.PHONY: all build test clean lint
all: build
build:
CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go
test:
go test -v -race ./...
clean:
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
lint:
go vet ./...
+264
View File
@@ -0,0 +1,264 @@
# CLIProxyAPI Command Code Plugin (`commandcode`)
[![Go Version](https://img.shields.io/badge/Go-1.22%2B-blue.svg)](https://golang.org)
[![CLIProxyAPI Plugin ABI](https://img.shields.io/badge/C%20ABI-v1-emerald.svg)](https://help.router-for.me/plugin/development.html)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 凭据认证、上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。
---
## 目录
- [功能特性](#功能特性)
- [系统架构](#系统架构)
- [快速开始](#快速开始)
- [构建插件](#构建插件)
- [安装与目录结构](#安装与目录结构)
- [宿主配置 (`config.yaml`)](#宿主配置-configyaml)
- [凭据文件配置](#凭据文件配置)
- [管理端点与资源页](#管理端点与资源页)
- [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard)
- [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get)
- [3. 管理 API: 测试用量 (`POST`)](#3-管理-api-测试用量-post)
- [用量数据结构说明](#用量数据结构说明)
- [开发与测试](#开发与测试)
- [许可证](#许可证)
---
## 功能特性
1. **标准 C ABI 兼容**:
- 导出 `cliproxy_plugin_init`、`cliproxyPluginCall`、`cliproxyPluginFree`、`cliproxyPluginShutdown`。
- 遵照 CLIProxyAPI 官方 JSON Envelope 规范(`ok`, `result`, `error`)。
2. **双核心能力声明**:
- `auth_provider`: 参与凭据识别、加载、解析与刷新。
- `management_api`: 注册插件自有的管理端点与浏览器资源页面。
3. **凭据自动解析 (`auth.parse`)**:
- 自动识别 `commandcode-*.json` 凭据文件、`type: "commandcode"` 配置或包含 `session_token` / Cookie 的凭据。
- 提取并规范化 `__Secure-commandcode_prod_.session_token`,存入宿主持久化凭据库。
4. **精确用量与双滑动窗口限额解析**:
- 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
5. **嵌入式纯单文件 QuotaCard 资源页**:
- 页面挂载于 `/v0/resource/plugins/commandcode/quota`。
- 零外部 CDN 依赖,纯内置 HTML + CSS + JS,深色/浅色模式自适应。
- 具有进度条颜色变化、5小时/周限额卡片、秒级动态重置倒计时、同源 `localStorage` 鉴权与一键刷新。
---
## 系统架构
```
┌────────────────────────────────────────────────────────┐
│ CLIProxyAPI │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────┐ │
│ │ Auth Management │ │ Management Center │ │
│ │ (reads auths/*.json) │ │ (/v0/management) │ │
│ └───────────┬─────────────┘ └──────────┬──────────┘ │
│ │ C ABI │ C ABI │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ cliproxy-plugin-commandcode.dylib/.so │ │
│ │ │ │
│ │ • auth.identifier / auth.parse │ │
│ │ • management.register / management.handle │ │
│ │ • Usage Parser & Window Limits Formatter │ │
│ │ • Embedded Single-file HTML/CSS/JS QuotaCard │ │
│ └───────────────────────────┬──────────────────────┘ │
│ │ │
│ │ host.http.do │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Host Transport / Proxy Pipeline │ │
│ └───────────────────────────┬──────────────────────┘ │
└──────────────────────────────┼─────────────────────────┘
│ Upstream HTTPS
▼
https://api.commandcode.ai/internal/billing/credits
```
---
## 快速开始
### 构建插件
项目提供标准的 `Makefile`,可直接编译与操作系统相对应的 C 共享动态库:
```bash
# 自动编译出 commandcode.dylib (macOS) 或 commandcode.so (Linux)
make build
# 运行完整单元测试与竞态检测
make test
# 清理构建产物
make clean
```
### 安装与目录结构
将编译出的动态库放入 CLIProxyAPI 的插件目录中:
```bash
# macOS
mkdir -p plugins/darwin/arm64
cp commandcode.dylib plugins/darwin/arm64/commandcode.dylib
# Linux
mkdir -p plugins/linux/amd64
cp commandcode.so plugins/linux/amd64/commandcode.so
```
### 宿主配置 (`config.yaml`)
在 CLIProxyAPI 的 `config.yaml` 中启用插件并配置默认参数:
```yaml
plugins:
enabled: true
dir: "plugins"
configs:
commandcode:
enabled: true
priority: 1
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN"
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
```
### 凭据文件配置
除了在 `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` 会自动拦截并完成凭据加载。
---
## 管理端点与资源页
### 1. 浏览器资源页 (`QuotaCard`)
- **访问路径**:`GET http://<cpa-host>:8317/v0/resource/plugins/commandcode/quota`
- **菜单名**:`Command Code 配额`
- **说明**:
- 资源请求本身无需经过管理认证,可在浏览器中直接打开或嵌入仪表盘。
- 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/usage` 请求数据。
- 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key 或测试 Session Token。
### 2. 管理 API: 查询用量 (`GET`)
- **端点**:`GET /v0/management/plugins/commandcode/usage`
- **认证**:需要管理密钥 (`Authorization: Bearer <MANAGEMENT_KEY>` 或 `X-Management-Key: <MANAGEMENT_KEY>`)
- **可选查询参数**:
- `session_token`: 临时覆盖查询的 token
- `api_base`: 临时覆盖的上游基础 URL
- **响应示例**:
```json
{
"ok": true,
"credits": {
"monthly_credits": 1000.0,
"opensource_monthly_credits": 500.0,
"total_credits": 1500.0,
"details": {
"monthlyCredits": 1000,
"opensourceMonthlyCredits": 500
}
},
"window_limits": {
"five_hour": {
"used": 12.5,
"cap": 100.0,
"remaining": 87.5,
"percentage": 12.5,
"exceeded": false,
"reset_at": "2025-03-04T16:30:00Z",
"reset_in_seconds": 7200
},
"weekly": {
"used": 150.0,
"cap": 1000.0,
"remaining": 850.0,
"percentage": 15.0,
"exceeded": false,
"reset_at": "2025-03-10T00:00:00Z",
"reset_in_seconds": 475200
}
},
"updated_at": "2025-03-04T14:30:00Z"
}
```
### 3. 管理 API: 测试用量 (`POST`)
- **端点**:`POST /v0/management/plugins/commandcode/usage`
- **请求体**:
```json
{
"session_token": "YOUR_TEMPORARY_TOKEN",
"api_base": "https://api.commandcode.ai"
}
```
---
## 用量数据结构说明
| 字段 | 类型 | 说明 |
| :--- | :--- | :--- |
| `credits.monthly_credits` | `float64` | 当前账单周期的月度基础 Credits 额度 |
| `credits.opensource_monthly_credits` | `float64` | 开源项目贡献者获得的奖励额度 |
| `credits.total_credits` | `float64` | 可用 Credits 总计 (`monthly + opensource`) |
| `window_limits.five_hour.used` | `float64` | 5小时滑动窗口内已消耗的量 |
| `window_limits.five_hour.cap` | `float64` | 5小时滑动窗口上限 |
| `window_limits.five_hour.remaining` | `float64` | 5小时滑动窗口剩余可用量 |
| `window_limits.five_hour.percentage` | `float64` | 5小时窗口使用百分比(0-100%) |
| `window_limits.five_hour.exceeded` | `bool` | 是否已触发 5 小时限额熔断 |
| `window_limits.five_hour.reset_at` | `string` | 5小时窗口重置时间的 RFC3339 字符串 |
| `window_limits.five_hour.reset_in_seconds`| `int64` | 距离 5 小时窗口重置的剩余秒数 |
| `window_limits.weekly.*` | - | 每周限额对应指标(结构同 5 小时窗口) |
---
## 开发与测试
```bash
# 运行单元测试
go test -v ./...
# 运行代码规范检查
go vet ./...
# 运行竞态检查测试
go test -race -v ./...
```
---
## 许可证
本项目基于 [MIT License](LICENSE) 开源。
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="200" height="200" rx="40" fill="url(#grad1)" />
<g fill="none" stroke="#ffffff" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">
<polyline points="70 65 35 100 70 135" />
<polyline points="130 65 165 100 130 135" />
<line x1="115" y1="60" x2="85" y2="140" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 656 B

+5
View File
@@ -0,0 +1,5 @@
module github.com/zgs225/cliproxy-plugin-commandcode
go 1.22
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+155
View File
@@ -0,0 +1,155 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
static const cliproxy_host_api* stored_host;
static void store_host_api(const cliproxy_host_api* host) {
stored_host = host;
}
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (stored_host == NULL || stored_host->call == NULL) {
return 1;
}
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
}
static void free_host_buffer(void* ptr, size_t len) {
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
stored_host->free_buffer(ptr, len);
}
}
*/
import "C"
import (
"fmt"
"unsafe"
"github.com/zgs225/cliproxy-plugin-commandcode/plugin"
)
const abiVersion uint32 = 1
func main() {}
func init() {
plugin.SetHostCaller(callHost)
}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, pluginAPI *C.cliproxy_plugin_api) C.int {
if pluginAPI == nil {
return 1
}
C.store_host_api(host)
pluginAPI.abi_version = C.uint32_t(abiVersion)
pluginAPI.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
pluginAPI.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
pluginAPI.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, plugin.ErrorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := plugin.DefaultPlugin().HandleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, plugin.ErrorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
_ = len
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
func callHost(method string, payload []byte) ([]byte, error) {
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var response C.cliproxy_buffer
var req *C.uint8_t
if len(payload) > 0 {
req = (*C.uint8_t)(C.CBytes(payload))
defer C.free(unsafe.Pointer(req))
}
rc := C.call_host_api(cMethod, req, C.size_t(len(payload)), &response)
if rc != 0 {
return nil, fmt.Errorf("host call %s failed with code %d", method, int(rc))
}
if response.ptr == nil || response.len == 0 {
return nil, nil
}
defer C.free_host_buffer(response.ptr, response.len)
return C.GoBytes(response.ptr, C.int(response.len)), nil
}
+166
View File
@@ -0,0 +1,166 @@
package plugin
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
)
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.
func ExtractSessionToken(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if matches := cookieRegex.FindStringSubmatch(raw); len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
if strings.HasPrefix(raw, "__Secure-commandcode_prod_.session_token=") {
trimmed := strings.TrimPrefix(raw, "__Secure-commandcode_prod_.session_token=")
if idx := strings.Index(trimmed, ";"); idx != -1 {
trimmed = trimmed[:idx]
}
return strings.TrimSpace(trimmed)
}
return raw
}
// FormatSessionCookie ensures the token is formatted as the upstream Cookie header value.
func FormatSessionCookie(token string) string {
clean := ExtractSessionToken(token)
if clean == "" {
return ""
}
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
}
+166
View File
@@ -0,0 +1,166 @@
package plugin
import (
"encoding/json"
"testing"
)
func TestExtractSessionToken(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "raw token",
input: "abc123token",
expected: "abc123token",
},
{
name: "single cookie",
input: "__Secure-commandcode_prod_.session_token=secret_tok_123",
expected: "secret_tok_123",
},
{
name: "cookie with semicolons and trailing params",
input: "__Secure-commandcode_prod_.session_token=secret_tok_123; Path=/; Secure; HttpOnly",
expected: "secret_tok_123",
},
{
name: "multi-cookie string",
input: "some_other_cookie=xyz; __Secure-commandcode_prod_.session_token=secret_tok_123; foo=bar",
expected: "secret_tok_123",
},
{
name: "empty string",
input: " ",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractSessionToken(tt.input)
if got != tt.expected {
t.Errorf("ExtractSessionToken(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestFormatSessionCookie(t *testing.T) {
got := FormatSessionCookie("my-token")
want := "__Secure-commandcode_prod_.session_token=my-token"
if got != want {
t.Errorf("FormatSessionCookie() = %q, want %q", got, want)
}
gotCookie := FormatSessionCookie("__Secure-commandcode_prod_.session_token=my-token; Path=/")
if 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")
}
}
+208
View File
@@ -0,0 +1,208 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// RegisterManagement handles management.register method.
func RegisterManagement() (ManagementRegistrationResponse, error) {
return ManagementRegistrationResponse{
Routes: []ManagementRoute{
{
Method: http.MethodGet,
Path: "/plugins/commandcode/usage",
Description: "Query Command Code credits and window limits usage",
},
{
Method: http.MethodPost,
Path: "/plugins/commandcode/usage",
Description: "Query Command Code credits and window limits usage with custom session_token",
},
},
Resources: []ResourceRoute{
{
Path: "/quota",
Menu: "Command Code 配额",
Description: "Command Code 用量与限额卡片",
},
},
}, nil
}
// HandleManagement processes management.handle requests for API routes and resource pages.
func HandleManagement(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
method := strings.ToUpper(strings.TrimSpace(req.Method))
path := strings.TrimSpace(req.Path)
// 1. Serve Quota Resource Page
if method == http.MethodGet && (strings.HasSuffix(path, "/quota") || strings.HasSuffix(path, "/quota/")) {
return ManagementResponse{
StatusCode: http.StatusOK,
Headers: map[string][]string{
"Content-Type": {"text/html; charset=utf-8"},
},
Body: GetQuotaPageHTML(),
}, nil
}
// 2. Serve Usage API (GET / POST)
if strings.HasSuffix(path, "/plugins/commandcode/usage") || strings.HasSuffix(path, "/usage") {
switch method {
case http.MethodGet:
return handleGetUsage(ctx, req, cfg)
case http.MethodPost:
return handlePostUsage(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
}
}
// Unknown path
return ManagementResponse{
StatusCode: http.StatusNotFound,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: []byte(`{"ok":false,"error":"not found"}`),
}, nil
}
func handleGetUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
sessionToken := ""
apiBase := ""
// Check query params
if len(req.Query) > 0 {
if tokens, ok := req.Query["session_token"]; ok && len(tokens) > 0 {
sessionToken = tokens[0]
} else if tokens, ok := req.Query["token"]; ok && len(tokens) > 0 {
sessionToken = tokens[0]
}
if bases, ok := req.Query["api_base"]; ok && len(bases) > 0 {
apiBase = bases[0]
}
}
// Fallback to plugin config
if sessionToken == "" && cfg != nil {
sessionToken = cfg.GetSessionToken()
}
if apiBase == "" && cfg != nil {
apiBase = cfg.GetAPIBase()
}
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
}
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
var body struct {
SessionToken string `json:"session_token"`
Token string `json:"token"`
APIBase string `json:"api_base"`
}
if len(req.Body) > 0 {
_ = json.Unmarshal(req.Body, &body)
}
sessionToken := body.SessionToken
if sessionToken == "" {
sessionToken = body.Token
}
apiBase := body.APIBase
// Fallback to plugin config if body didn't specify
if sessionToken == "" && cfg != nil {
sessionToken = cfg.GetSessionToken()
}
if apiBase == "" && cfg != nil {
apiBase = cfg.GetAPIBase()
}
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
}
func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackID string) (ManagementResponse, error) {
if strings.TrimSpace(sessionToken) == "" {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
})
return ManagementResponse{
StatusCode: http.StatusBadRequest,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
raw, statusCode, errFetch := FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
if errFetch != nil {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"status_code": statusCode,
"error": errFetch.Error(),
})
if statusCode == 0 || statusCode == http.StatusOK {
statusCode = http.StatusBadGateway
}
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
if statusCode != http.StatusOK {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"status_code": statusCode,
"error": "upstream returned non-200 status",
"body": string(raw),
})
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
usage, errParse := ParseAndFormatUsage(raw, time.Now().UTC())
if errParse != nil {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "failed to parse upstream usage: " + errParse.Error(),
})
return ManagementResponse{
StatusCode: http.StatusBadGateway,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
resBytes, _ := json.Marshal(usage)
return ManagementResponse{
StatusCode: http.StatusOK,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
+152
View File
@@ -0,0 +1,152 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRegisterManagement(t *testing.T) {
resp, err := RegisterManagement()
if err != nil {
t.Fatalf("RegisterManagement error: %v", err)
}
if len(resp.Routes) != 2 {
t.Fatalf("len(Routes) = %d, want 2", len(resp.Routes))
}
if resp.Routes[0].Method != http.MethodGet || resp.Routes[0].Path != "/plugins/commandcode/usage" {
t.Errorf("Route 0 mismatch: %+v", resp.Routes[0])
}
if resp.Routes[1].Method != http.MethodPost || resp.Routes[1].Path != "/plugins/commandcode/usage" {
t.Errorf("Route 1 mismatch: %+v", resp.Routes[1])
}
if len(resp.Resources) != 1 {
t.Fatalf("len(Resources) = %d, want 1", len(resp.Resources))
}
if resp.Resources[0].Path != "/quota" || resp.Resources[0].Menu != "Command Code 配额" {
t.Errorf("Resource 0 mismatch: %+v", resp.Resources[0])
}
}
func TestHandleManagement_QuotaResource(t *testing.T) {
paths := []string{
"/quota",
"/v0/resource/plugins/commandcode/quota",
}
for _, p := range paths {
req := ManagementRequest{
Method: http.MethodGet,
Path: p,
}
resp, err := HandleManagement(context.Background(), req, nil)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("StatusCode = %d, want 200", resp.StatusCode)
}
ct := resp.Headers["Content-Type"]
if len(ct) == 0 || !strings.Contains(ct[0], "text/html") {
t.Errorf("Content-Type = %v, want text/html", ct)
}
bodyStr := string(resp.Body)
if !strings.Contains(bodyStr, "Command Code 配额") {
t.Errorf("Body does not contain expected title")
}
}
}
func TestHandleManagement_GetUsage(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"credits": {"monthlyCredits": 888},
"windowLimits": {"fiveHour": {"used": 2, "cap": 20}}
}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
cfg := &PluginConfig{
SessionToken: "configured-token",
APIBase: ts.URL,
}
req := ManagementRequest{
Method: http.MethodGet,
Path: "/v0/management/plugins/commandcode/usage",
}
resp, err := HandleManagement(context.Background(), req, cfg)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
var usage FormattedUsageResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("Unmarshal body error: %v", err)
}
if !usage.OK {
t.Fatal("expected OK=true")
}
if usage.Credits.MonthlyCredits != 888 {
t.Errorf("MonthlyCredits = %v, want 888", usage.Credits.MonthlyCredits)
}
}
func TestHandleManagement_PostUsage(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie := r.Header.Get("Cookie")
if !strings.Contains(cookie, "post-token-999") {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"credits": {"monthlyCredits": 666},
"windowLimits": {"fiveHour": {"used": 1, "cap": 10}}
}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
reqBody, _ := json.Marshal(map[string]string{
"session_token": "post-token-999",
"api_base": ts.URL,
})
req := ManagementRequest{
Method: http.MethodPost,
Path: "/plugins/commandcode/usage",
Body: reqBody,
}
resp, err := HandleManagement(context.Background(), req, nil)
if err != nil {
t.Fatalf("HandleManagement error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200, body=%s", resp.StatusCode, string(resp.Body))
}
var usage FormattedUsageResponse
if err := json.Unmarshal(resp.Body, &usage); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if usage.Credits.MonthlyCredits != 666 {
t.Errorf("MonthlyCredits = %v, want 666", usage.Credits.MonthlyCredits)
}
}
+285
View File
@@ -0,0 +1,285 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
const (
PluginID = "commandcode"
PluginName = "commandcode"
PluginVersion = "0.1.0"
PluginAuthor = "zgs225"
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
SchemaVersion1 = 1
)
// PluginConfig holds the runtime configuration parsed from YAML.
type PluginConfig struct {
mu sync.RWMutex
SessionToken string `yaml:"session_token" json:"session_token"`
APIBase string `yaml:"api_base" json:"api_base"`
}
// UpdateFromYAML updates the configuration from raw YAML bytes.
func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
if len(raw) == 0 {
return nil
}
var tmp struct {
SessionToken string `yaml:"session_token"`
APIBase string `yaml:"api_base"`
}
if err := yaml.Unmarshal(raw, &tmp); err != nil {
return fmt.Errorf("unmarshal config_yaml: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
if tmp.SessionToken != "" {
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
}
if tmp.APIBase != "" {
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
}
if c.APIBase == "" {
c.APIBase = DefaultAPIBase
}
return nil
}
// GetSessionToken safely returns the session token.
func (c *PluginConfig) GetSessionToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.SessionToken
}
// SetSessionToken safely sets the session token.
func (c *PluginConfig) SetSessionToken(token string) {
c.mu.Lock()
defer c.mu.Unlock()
c.SessionToken = ExtractSessionToken(token)
}
// GetAPIBase safely returns the API base URL.
func (c *PluginConfig) GetAPIBase() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.APIBase == "" {
return DefaultAPIBase
}
return c.APIBase
}
// Plugin encapsulates the Command Code plugin instance.
type Plugin struct {
config *PluginConfig
}
var (
defaultPlugin = NewPlugin()
)
// DefaultPlugin returns the singleton plugin instance.
func DefaultPlugin() *Plugin {
return defaultPlugin
}
// NewPlugin creates a new Plugin instance.
func NewPlugin() *Plugin {
return &Plugin{
config: &PluginConfig{
APIBase: DefaultAPIBase,
},
}
}
// HandleMethod dispatches an ABI call to the corresponding handler.
func (p *Plugin) HandleMethod(method string, requestBytes []byte) ([]byte, error) {
switch method {
case "plugin.register":
return p.handleRegister(requestBytes)
case "plugin.reconfigure":
return p.handleReconfigure(requestBytes)
case "plugin.quiesce", "plugin.shutdown":
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":
return p.handleManagementRegister()
case "management.handle":
return p.handleManagementHandle(requestBytes)
default:
return ErrorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
if len(raw) > 0 {
var req LifecycleRequest
if err := json.Unmarshal(raw, &req); err == nil && len(req.ConfigYAML) > 0 {
_ = p.config.UpdateFromYAML(req.ConfigYAML)
}
}
return OkEnvelope(Registration{
SchemaVersion: SchemaVersion1,
Metadata: Metadata{
Name: PluginName,
Version: PluginVersion,
Author: PluginAuthor,
GitHubRepository: PluginRepo,
Logo: PluginLogo,
ConfigFields: []ConfigField{
{
Name: "session_token",
Type: "string",
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
},
{
Name: "api_base",
Type: "string",
Description: "Command Code API base URL (default: https://api.commandcode.ai)",
},
},
},
Capabilities: RegistrationCapability{
AuthProvider: true,
ManagementAPI: true,
},
})
}
func (p *Plugin) handleReconfigure(raw []byte) ([]byte, error) {
if len(raw) > 0 {
var req LifecycleRequest
if err := json.Unmarshal(raw, &req); err == nil && len(req.ConfigYAML) > 0 {
_ = p.config.UpdateFromYAML(req.ConfigYAML)
}
}
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) {
resp, err := RegisterManagement()
if err != nil {
return ErrorEnvelope("management_register_error", err.Error()), nil
}
return OkEnvelope(resp)
}
func (p *Plugin) handleManagementHandle(raw []byte) ([]byte, error) {
var req ManagementRequest
if len(raw) > 0 {
if err := json.Unmarshal(raw, &req); err != nil {
return ErrorEnvelope("invalid_request", "failed to parse ManagementRequest: "+err.Error()), nil
}
}
resp, err := HandleManagement(context.Background(), req, p.config)
if err != nil {
return ErrorEnvelope("management_handle_error", err.Error()), nil
}
return OkEnvelope(resp)
}
// OkEnvelope builds a successful Envelope response.
func OkEnvelope(v any) ([]byte, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
return json.Marshal(Envelope{OK: true, Result: raw})
}
// ErrorEnvelope builds an error Envelope response.
func ErrorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(Envelope{
OK: false,
Error: &EnvelopeError{
Code: code,
Message: message,
},
})
return raw
}
+139
View File
@@ -0,0 +1,139 @@
package plugin
import (
"encoding/json"
"testing"
)
func TestPluginRegister_And_Reconfigure(t *testing.T) {
p := NewPlugin()
configYAML := []byte(`
session_token: "my-yaml-token"
api_base: "https://custom-api.commandcode.ai"
`)
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
// Test plugin.register
regBytes, err := p.HandleMethod("plugin.register", lifecycleReq)
if err != nil {
t.Fatalf("handleMethod(plugin.register) error: %v", err)
}
var env Envelope
if err := json.Unmarshal(regBytes, &env); err != nil {
t.Fatalf("unmarshal envelope error: %v", err)
}
if !env.OK {
t.Fatalf("expected env.OK=true, got false: %+v", env.Error)
}
var reg Registration
if err := json.Unmarshal(env.Result, &reg); err != nil {
t.Fatalf("unmarshal registration error: %v", err)
}
if reg.Metadata.Name != PluginName {
t.Errorf("Metadata.Name = %q, want %q", reg.Metadata.Name, PluginName)
}
if reg.Metadata.Version != PluginVersion {
t.Errorf("Metadata.Version = %q, want %q", reg.Metadata.Version, PluginVersion)
}
if !reg.Capabilities.AuthProvider {
t.Errorf("Capabilities.AuthProvider = false, want true")
}
if !reg.Capabilities.ManagementAPI {
t.Errorf("Capabilities.ManagementAPI = false, want true")
}
// Verify config fields
if len(reg.Metadata.ConfigFields) != 2 {
t.Fatalf("ConfigFields len = %d, want 2", len(reg.Metadata.ConfigFields))
}
fieldNames := map[string]bool{}
for _, f := range reg.Metadata.ConfigFields {
fieldNames[f.Name] = true
}
if !fieldNames["session_token"] || !fieldNames["api_base"] {
t.Errorf("ConfigFields missing session_token or api_base: %+v", reg.Metadata.ConfigFields)
}
// Verify config parsed
if p.config.GetSessionToken() != "my-yaml-token" {
t.Errorf("SessionToken = %q, want my-yaml-token", p.config.GetSessionToken())
}
if p.config.GetAPIBase() != "https://custom-api.commandcode.ai" {
t.Errorf("APIBase = %q, want https://custom-api.commandcode.ai", p.config.GetAPIBase())
}
// Test plugin.reconfigure
reconfYAML := []byte(`
session_token: "new-token-abc"
`)
reconfReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: reconfYAML})
reconfBytes, err := p.HandleMethod("plugin.reconfigure", reconfReq)
if err != nil {
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
}
var reconfEnv Envelope
if err := json.Unmarshal(reconfBytes, &reconfEnv); err != nil || !reconfEnv.OK {
t.Fatalf("reconfigure failed: %+v", reconfEnv)
}
if p.config.GetSessionToken() != "new-token-abc" {
t.Errorf("SessionToken after reconfigure = %q, want new-token-abc", p.config.GetSessionToken())
}
}
func TestPluginAuthIdentifier(t *testing.T) {
p := NewPlugin()
raw, err := p.HandleMethod("auth.identifier", nil)
if err != nil {
t.Fatalf("auth.identifier error: %v", err)
}
var env Envelope
if err := json.Unmarshal(raw, &env); err != nil || !env.OK {
t.Fatalf("envelope error: %+v", env)
}
var idResp IdentifierResponse
if err := json.Unmarshal(env.Result, &idResp); err != nil {
t.Fatalf("unmarshal idResp error: %v", err)
}
if idResp.Identifier != PluginID {
t.Errorf("Identifier = %q, want %q", idResp.Identifier, PluginID)
}
}
func TestPluginUnknownMethod(t *testing.T) {
p := NewPlugin()
raw, err := p.HandleMethod("unknown.method.test", nil)
if err != nil {
t.Fatalf("expected no go error, got %v", err)
}
var env Envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if env.OK {
t.Fatal("expected env.OK=false for unknown method")
}
if env.Error == nil || env.Error.Code != "unknown_method" {
t.Errorf("Error = %+v, want code=unknown_method", env.Error)
}
}
func TestEnvelopeError(t *testing.T) {
raw := ErrorEnvelope("test_code", "test error message")
var env Envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if env.OK {
t.Fatal("expected OK=false")
}
if env.Error.Code != "test_code" || env.Error.Message != "test error message" {
t.Errorf("env.Error = %+v", env.Error)
}
}
+1028
View File
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
package plugin
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// Envelope matches the CLIProxyAPI ABI JSON Envelope.
type Envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *EnvelopeError `json:"error,omitempty"`
}
// EnvelopeError represents an error inside the ABI Envelope.
type EnvelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
}
// LifecycleRequest represents the payload for plugin.register or plugin.reconfigure.
type LifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
}
// Registration describes the plugin registration response.
type Registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata Metadata `json:"metadata"`
Capabilities RegistrationCapability `json:"capabilities"`
}
// Metadata describes the plugin metadata.
type Metadata struct {
Name string `json:"Name"`
Version string `json:"Version"`
Author string `json:"Author,omitempty"`
GitHubRepository string `json:"GitHubRepository,omitempty"`
Logo string `json:"Logo,omitempty"`
ConfigFields []ConfigField `json:"ConfigFields,omitempty"`
}
// ConfigField describes one configuration field for the plugin.
type ConfigField struct {
Name string `json:"Name"`
Type string `json:"Type"`
EnumValues []string `json:"EnumValues,omitempty"`
Description string `json:"Description"`
}
// RegistrationCapability declares the capabilities implemented by this plugin.
type RegistrationCapability struct {
AuthProvider bool `json:"auth_provider"`
ManagementAPI bool `json:"management_api"`
}
// 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.
type ManagementRegistrationResponse struct {
Routes []ManagementRoute `json:"routes,omitempty"`
Resources []ResourceRoute `json:"resources,omitempty"`
}
// ManagementRoute describes one Management API route.
type ManagementRoute struct {
Method string `json:"Method"`
Path string `json:"Path"`
Menu string `json:"Menu,omitempty"`
Description string `json:"Description,omitempty"`
}
// ResourceRoute describes one browser-navigable resource route.
type ResourceRoute struct {
Path string `json:"Path"`
Menu string `json:"Menu"`
Description string `json:"Description"`
}
// ManagementRequest is received by management.handle.
type ManagementRequest struct {
Method string `json:"Method"`
Path string `json:"Path"`
Headers map[string][]string `json:"Headers"`
Query map[string][]string `json:"Query"`
Body []byte `json:"Body"`
HostCallbackID string `json:"host_callback_id,omitempty"`
}
// ManagementResponse is returned by management.handle.
type ManagementResponse struct {
StatusCode int `json:"StatusCode"`
Headers map[string][]string `json:"Headers"`
Body []byte `json:"Body"`
}
// HostHTTPRequest describes a request dispatched through host.http.do.
type HostHTTPRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string][]string `json:"headers,omitempty"`
Body []byte `json:"body,omitempty"`
HostCallbackID string `json:"host_callback_id,omitempty"`
}
// HostHTTPResponse describes a response received from host.http.do.
type HostHTTPResponse struct {
StatusCode int `json:"StatusCode"`
Headers map[string][]string `json:"Headers"`
Body []byte `json:"Body"`
}
// FlexibleTime handles parsing timestamps from upstream that may be unix seconds, unix milliseconds, or RFC3339 strings.
type FlexibleTime struct {
time.Time
}
// UnmarshalJSON parses various timestamp formats.
func (ft *FlexibleTime) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\" \t\r\n")
if s == "" || s == "null" || s == "0" {
ft.Time = time.Time{}
return nil
}
// Try parsing as integer / float number (Unix timestamp)
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
if n > 1e11 {
// Milliseconds
ft.Time = time.UnixMilli(n).UTC()
} else {
// Seconds
ft.Time = time.Unix(n, 0).UTC()
}
return nil
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
sec := int64(f)
if sec > 1e11 {
ft.Time = time.UnixMilli(sec).UTC()
} else {
ft.Time = time.Unix(sec, 0).UTC()
}
return nil
}
// Try standard RFC3339 / ISO8601 layouts
formats := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
}
for _, layout := range formats {
if t, err := time.Parse(layout, s); err == nil {
ft.Time = t.UTC()
return nil
}
}
return fmt.Errorf("cannot parse %q as FlexibleTime", string(b))
}
// UpstreamCreditsResponse reflects the payload returned by Command Code's /internal/billing/credits.
type UpstreamCreditsResponse struct {
Credits map[string]any `json:"credits"`
WindowLimits UpstreamWindowLimits `json:"windowLimits"`
}
// UpstreamWindowLimits carries fiveHour and weekly window metrics.
type UpstreamWindowLimits struct {
FiveHour UpstreamWindowLimit `json:"fiveHour"`
Weekly UpstreamWindowLimit `json:"weekly"`
}
// UpstreamWindowLimit represents one quota window from upstream.
type UpstreamWindowLimit struct {
Used float64 `json:"used"`
Cap float64 `json:"cap"`
Exceeded bool `json:"exceeded"`
ResetAt FlexibleTime `json:"resetAt"`
}
// UsageCreditsData is the formatted credits section.
type UsageCreditsData struct {
MonthlyCredits float64 `json:"monthly_credits"`
OpensourceMonthlyCredits float64 `json:"opensource_monthly_credits"`
TotalCredits float64 `json:"total_credits"`
Details map[string]any `json:"details,omitempty"`
}
// UsageWindowLimitData is the formatted window limit section.
type UsageWindowLimitData struct {
Used float64 `json:"used"`
Cap float64 `json:"cap"`
Remaining float64 `json:"remaining"`
Percentage float64 `json:"percentage"`
Exceeded bool `json:"exceeded"`
ResetAt string `json:"reset_at"`
ResetInSeconds int64 `json:"reset_in_seconds"`
}
// UsageWindowLimitsData contains both windows.
type UsageWindowLimitsData struct {
FiveHour UsageWindowLimitData `json:"five_hour"`
Weekly UsageWindowLimitData `json:"weekly"`
}
// FormattedUsageData is the complete formatted usage payload.
type FormattedUsageData struct {
Credits UsageCreditsData `json:"credits"`
WindowLimits UsageWindowLimitsData `json:"window_limits"`
UpdatedAt string `json:"updated_at"`
}
// FormattedUsageResponse is returned by GET /plugins/commandcode/usage and POST /plugins/commandcode/usage.
type FormattedUsageResponse struct {
OK bool `json:"ok"`
Data FormattedUsageData `json:"data"`
Credits UsageCreditsData `json:"credits"`
WindowLimits UsageWindowLimitsData `json:"window_limits"`
UpdatedAt string `json:"updated_at"`
Error string `json:"error,omitempty"`
}
+259
View File
@@ -0,0 +1,259 @@
package plugin
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strings"
"time"
)
const (
DefaultAPIBase = "https://api.commandcode.ai"
)
// HTTPDoer abstracts HTTP requests for testing and fallback.
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// HostCallerFunc is the signature for calling the host API via C ABI.
type HostCallerFunc func(method string, payload []byte) ([]byte, error)
var (
defaultHTTPClient HTTPDoer = &http.Client{Timeout: 15 * time.Second}
hostCaller HostCallerFunc // Set by main if host API is available
)
// SetHostCaller registers the host API callback runner.
func SetHostCaller(fn HostCallerFunc) {
hostCaller = fn
}
// SetDefaultHTTPClient overrides the default standard HTTP client (useful for unit tests).
func SetDefaultHTTPClient(client HTTPDoer) {
if client != nil {
defaultHTTPClient = client
}
}
// 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) {
cleanToken := ExtractSessionToken(sessionToken)
if cleanToken == "" {
return nil, http.StatusBadRequest, errors.New("missing session_token: please provide a valid Command Code session token")
}
if apiBase == "" {
apiBase = DefaultAPIBase
}
url := fmt.Sprintf("%s/internal/billing/credits", strings.TrimRight(apiBase, "/"))
cookieValue := FormatSessionCookie(cleanToken)
// 1. Try host.http.do if hostCaller is configured
if hostCaller != nil {
reqPayload := HostHTTPRequest{
Method: http.MethodGet,
URL: url,
Headers: map[string][]string{
"Cookie": {cookieValue},
"Accept": {"application/json"},
"User-Agent": {"cliproxy-plugin-commandcode/0.1.0"},
},
HostCallbackID: hostCallbackID,
}
rawReq, errMarshal := json.Marshal(reqPayload)
if errMarshal == nil {
respBytes, errCall := hostCaller("host.http.do", rawReq)
if errCall == nil && len(respBytes) > 0 {
var env Envelope
if errEnv := json.Unmarshal(respBytes, &env); errEnv == nil {
if !env.OK {
errMsg := "host HTTP request failed"
if env.Error != nil {
errMsg = fmt.Sprintf("%s: %s", env.Error.Code, env.Error.Message)
}
return nil, http.StatusBadGateway, fmt.Errorf("host.http.do error: %s", errMsg)
}
var hostResp HostHTTPResponse
if errResp := json.Unmarshal(env.Result, &hostResp); errResp == nil {
// hostResp.Body is automatically base64-decoded by json.Unmarshal for []byte
status := hostResp.StatusCode
if status == 0 {
status = http.StatusOK
}
return hostResp.Body, status, nil
}
}
}
}
// If hostCaller fails, seamlessly fallback to net/http
}
// 2. Fallback to Go net/http client
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if errNew != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("create HTTP request: %w", errNew)
}
httpReq.Header.Set("Cookie", cookieValue)
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", "cliproxy-plugin-commandcode/0.1.0")
res, errDo := defaultHTTPClient.Do(httpReq)
if errDo != nil {
return nil, http.StatusBadGateway, fmt.Errorf("upstream request failed: %w", errDo)
}
defer func() {
_ = res.Body.Close()
}()
body, errRead := io.ReadAll(res.Body)
if errRead != nil {
return nil, http.StatusBadGateway, fmt.Errorf("read upstream response body: %w", errRead)
}
return body, res.StatusCode, nil
}
// ParseAndFormatUsage parses upstream credits JSON into structured usage metrics.
func ParseAndFormatUsage(raw []byte, now time.Time) (*FormattedUsageResponse, error) {
if len(raw) == 0 {
return nil, errors.New("empty response body from upstream")
}
// First try to parse as UpstreamCreditsResponse
var upstream UpstreamCreditsResponse
if err := json.Unmarshal(raw, &upstream); err != nil {
// Fallback: check if wrapped in { "data": { ... } }
var wrapped struct {
Data UpstreamCreditsResponse `json:"data"`
}
if errWrap := json.Unmarshal(raw, &wrapped); errWrap == nil && (len(wrapped.Data.Credits) > 0 || wrapped.Data.WindowLimits.FiveHour.Cap > 0) {
upstream = wrapped.Data
} else {
return nil, fmt.Errorf("unmarshal upstream response: %w", err)
}
}
if now.IsZero() {
now = time.Now().UTC()
}
// Format credits
creditsData := formatCredits(upstream.Credits)
// Format window limits
windowLimitsData := formatWindowLimits(upstream.WindowLimits, now)
nowRFC := now.Format(time.RFC3339)
data := FormattedUsageData{
Credits: creditsData,
WindowLimits: windowLimitsData,
UpdatedAt: nowRFC,
}
return &FormattedUsageResponse{
OK: true,
Data: data,
Credits: creditsData,
WindowLimits: windowLimitsData,
UpdatedAt: nowRFC,
}, nil
}
func formatCredits(credits map[string]any) UsageCreditsData {
data := UsageCreditsData{
Details: credits,
}
if credits == nil {
return data
}
data.MonthlyCredits = getFloatFromMap(credits, "monthlyCredits", "monthly_credits")
data.OpensourceMonthlyCredits = getFloatFromMap(credits, "opensourceMonthlyCredits", "opensource_monthly_credits")
data.TotalCredits = data.MonthlyCredits + data.OpensourceMonthlyCredits
return data
}
func formatWindowLimits(upstream UpstreamWindowLimits, now time.Time) UsageWindowLimitsData {
return UsageWindowLimitsData{
FiveHour: formatSingleWindow(upstream.FiveHour, now),
Weekly: formatSingleWindow(upstream.Weekly, now),
}
}
func formatSingleWindow(w UpstreamWindowLimit, now time.Time) UsageWindowLimitData {
var remaining float64
var percentage float64
if w.Cap > 0 {
remaining = w.Cap - w.Used
if remaining < 0 {
remaining = 0
}
percentage = (w.Used / w.Cap) * 100.0
if percentage > 100.0 {
percentage = 100.0
}
percentage = math.Round(percentage*100) / 100
}
var resetAtStr string
var resetInSeconds int64
if !w.ResetAt.IsZero() {
resetAtStr = w.ResetAt.UTC().Format(time.RFC3339)
diff := w.ResetAt.UTC().Sub(now)
if diff > 0 {
resetInSeconds = int64(diff.Seconds())
} else {
resetInSeconds = 0
}
}
return UsageWindowLimitData{
Used: w.Used,
Cap: w.Cap,
Remaining: remaining,
Percentage: percentage,
Exceeded: w.Exceeded,
ResetAt: resetAtStr,
ResetInSeconds: resetInSeconds,
}
}
func getFloatFromMap(m map[string]any, keys ...string) float64 {
for _, key := range keys {
if val, exists := m[key]; exists && val != nil {
switch v := val.(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case json.Number:
if f, err := v.Float64(); err == nil {
return f
}
}
}
}
return 0
}
// DecodeBase64OrRaw tries to decode base64, returning raw if not base64.
func DecodeBase64OrRaw(in []byte) []byte {
decoded, err := base64.StdEncoding.DecodeString(string(in))
if err == nil && len(decoded) > 0 {
return decoded
}
return in
}
+222
View File
@@ -0,0 +1,222 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestFlexibleTime(t *testing.T) {
tests := []struct {
name string
input string
wantYear int
}{
{
name: "unix seconds",
input: `{"resetAt": 1741123456}`,
wantYear: 2025,
},
{
name: "unix milliseconds",
input: `{"resetAt": 1741123456000}`,
wantYear: 2025,
},
{
name: "RFC3339 string",
input: `{"resetAt": "2025-06-15T12:00:00Z"}`,
wantYear: 2025,
},
{
name: "null",
input: `{"resetAt": null}`,
wantYear: 1, // Zero time year
},
{
name: "empty string",
input: `{"resetAt": ""}`,
wantYear: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var res struct {
ResetAt FlexibleTime `json:"resetAt"`
}
if err := json.Unmarshal([]byte(tt.input), &res); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if res.ResetAt.Year() != tt.wantYear {
t.Errorf("Year = %d, want %d", res.ResetAt.Year(), tt.wantYear)
}
})
}
}
func TestParseAndFormatUsage(t *testing.T) {
raw := []byte(`{
"credits": {
"monthlyCredits": 1000.0,
"opensourceMonthlyCredits": 500.0,
"extraBonus": 50.0
},
"windowLimits": {
"fiveHour": {
"used": 25.0,
"cap": 100.0,
"exceeded": false,
"resetAt": 1741123456
},
"weekly": {
"used": 200.0,
"cap": 1000.0,
"exceeded": false,
"resetAt": "2025-03-10T12:00:00Z"
}
}
}`)
now := time.Date(2025, 3, 4, 12, 0, 0, 0, time.UTC)
usage, err := ParseAndFormatUsage(raw, now)
if err != nil {
t.Fatalf("ParseAndFormatUsage error: %v", err)
}
if !usage.OK {
t.Fatal("expected OK=true")
}
// Verify credits
if usage.Credits.MonthlyCredits != 1000.0 {
t.Errorf("MonthlyCredits = %v, want 1000", usage.Credits.MonthlyCredits)
}
if usage.Credits.OpensourceMonthlyCredits != 500.0 {
t.Errorf("OpensourceMonthlyCredits = %v, want 500", usage.Credits.OpensourceMonthlyCredits)
}
if usage.Credits.TotalCredits != 1500.0 {
t.Errorf("TotalCredits = %v, want 1500", usage.Credits.TotalCredits)
}
// Verify 5-hour window
fiveHour := usage.WindowLimits.FiveHour
if fiveHour.Used != 25.0 {
t.Errorf("FiveHour Used = %v, want 25", fiveHour.Used)
}
if fiveHour.Cap != 100.0 {
t.Errorf("FiveHour Cap = %v, want 100", fiveHour.Cap)
}
if fiveHour.Remaining != 75.0 {
t.Errorf("FiveHour Remaining = %v, want 75", fiveHour.Remaining)
}
if fiveHour.Percentage != 25.0 {
t.Errorf("FiveHour Percentage = %v, want 25", fiveHour.Percentage)
}
if fiveHour.Exceeded {
t.Errorf("FiveHour Exceeded = true, want false")
}
// Verify weekly window
weekly := usage.WindowLimits.Weekly
if weekly.Used != 200.0 {
t.Errorf("Weekly Used = %v, want 200", weekly.Used)
}
if weekly.Cap != 1000.0 {
t.Errorf("Weekly Cap = %v, want 1000", weekly.Cap)
}
if weekly.Remaining != 800.0 {
t.Errorf("Weekly Remaining = %v, want 800", weekly.Remaining)
}
if weekly.Percentage != 20.0 {
t.Errorf("Weekly Percentage = %v, want 20", weekly.Percentage)
}
if weekly.ResetAt != "2025-03-10T12:00:00Z" {
t.Errorf("Weekly ResetAt = %v, want 2025-03-10T12:00:00Z", weekly.ResetAt)
}
}
func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/billing/credits" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
cookie := r.Header.Get("Cookie")
expectedCookie := "__Secure-commandcode_prod_.session_token=test-session-123"
if cookie != expectedCookie {
t.Errorf("Cookie = %q, want %q", cookie, expectedCookie)
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":100},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
}))
defer ts.Close()
// Ensure hostCaller is nil for fallback test
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
body, status, err := FetchCreditsRaw(context.Background(), ts.URL, "test-session-123", "")
if err != nil {
t.Fatalf("FetchCreditsRaw error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if len(body) == 0 {
t.Fatal("expected non-empty body")
}
usage, errParse := ParseAndFormatUsage(body, time.Time{})
if errParse != nil {
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
}
if usage.Credits.MonthlyCredits != 100 {
t.Errorf("MonthlyCredits = %v, want 100", usage.Credits.MonthlyCredits)
}
}
func TestFetchCreditsRaw_MissingToken(t *testing.T) {
_, status, err := FetchCreditsRaw(context.Background(), "", "", "")
if err == nil {
t.Fatal("expected error for missing token")
}
if status != http.StatusBadRequest {
t.Errorf("status = %d, want 400", status)
}
}
func TestFetchCreditsRaw_HostCaller(t *testing.T) {
mockResponsePayload := []byte(`{"credits":{"monthlyCredits":500},"windowLimits":{"fiveHour":{"used":5,"cap":50}}}`)
SetHostCaller(func(method string, payload []byte) ([]byte, error) {
if method != "host.http.do" {
t.Errorf("method = %s, want host.http.do", method)
}
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 := FetchCreditsRaw(context.Background(), "https://api.commandcode.ai", "mock-token", "cb-123")
if err != nil {
t.Fatalf("FetchCreditsRaw 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))
}
}