mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 03:32:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016929f9a7 | ||
|
|
680a40ebc8 | ||
|
|
4adda19b72 | ||
|
|
dc6c2d18b3 | ||
|
|
688a7f395f |
@@ -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** 上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
- [构建插件](#构建插件)
|
- [构建插件](#构建插件)
|
||||||
- [安装与目录结构](#安装与目录结构)
|
- [安装与目录结构](#安装与目录结构)
|
||||||
- [宿主配置 (`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)
|
||||||
@@ -32,12 +31,12 @@
|
|||||||
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`。
|
- 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。
|
||||||
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
|
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
|
||||||
@@ -55,16 +54,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 │ │
|
||||||
@@ -126,34 +124,10 @@ plugins:
|
|||||||
commandcode:
|
commandcode:
|
||||||
enabled: true
|
enabled: true
|
||||||
priority: 1
|
priority: 1
|
||||||
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN"
|
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
|
||||||
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
|
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` 会自动拦截并完成凭据加载。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 管理端点与资源页
|
## 管理端点与资源页
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
7830fab0eb763247602fd4815549a13eeb25841afbe2d3a047d25b9f4b9cf86d commandcode_0.2.2_linux_amd64.zip
|
||||||
Binary file not shown.
-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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-1
@@ -182,7 +182,16 @@ 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 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,
|
||||||
|
|||||||
+1
-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.2.2"
|
||||||
PluginAuthor = "zgs225"
|
PluginAuthor = "zgs225"
|
||||||
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
||||||
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
||||||
@@ -113,17 +112,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":
|
||||||
@@ -163,7 +151,6 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Capabilities: RegistrationCapability{
|
Capabilities: RegistrationCapability{
|
||||||
AuthProvider: true,
|
|
||||||
ManagementAPI: true,
|
ManagementAPI: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -179,67 +166,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 {
|
||||||
|
|||||||
+8
-10
@@ -39,8 +39,8 @@ 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")
|
||||||
@@ -84,7 +84,7 @@ session_token: "new-token-abc"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPluginAuthIdentifier(t *testing.T) {
|
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 +92,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+232
-16
@@ -37,8 +37,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
--radius-lg: 16px;
|
--radius-lg: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auto mode fallback: when no explicit data-theme is set, follow OS dark preference */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme="light"]):not([data-theme="white"]) {
|
||||||
--bg-page: #0b0f19;
|
--bg-page: #0b0f19;
|
||||||
--bg-card: #151d30;
|
--bg-card: #151d30;
|
||||||
--bg-subtle: #1e293b;
|
--bg-subtle: #1e293b;
|
||||||
@@ -61,6 +62,53 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Explicit dark theme from Management Center */
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg-page: #0b0f19;
|
||||||
|
--bg-card: #151d30;
|
||||||
|
--bg-subtle: #1e293b;
|
||||||
|
--border-color: #334155;
|
||||||
|
--text-main: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--text-dim: #64748b;
|
||||||
|
--primary: #60a5fa;
|
||||||
|
--primary-hover: #3b82f6;
|
||||||
|
--primary-subtle: rgba(59, 130, 246, 0.12);
|
||||||
|
--success: #34d399;
|
||||||
|
--success-subtle: rgba(16, 185, 129, 0.12);
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-subtle: rgba(245, 158, 11, 0.12);
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-subtle: rgba(239, 68, 68, 0.12);
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Explicit white / light theme from Management Center: overrides OS dark preference */
|
||||||
|
:root[data-theme="white"],
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg-page: #f8fafc;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-subtle: #f1f5f9;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--text-main: #0f172a;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-hover: #2563eb;
|
||||||
|
--primary-subtle: #eff6ff;
|
||||||
|
--success: #10b981;
|
||||||
|
--success-subtle: #ecfdf5;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--warning-subtle: #fffbeb;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--danger-subtle: #fef2f2;
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -141,6 +189,34 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
border: 1px solid var(--primary);
|
border: 1px solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.15));
|
||||||
|
color: #8b5cf6;
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.35);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .plan-tag {
|
||||||
|
background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(96, 165, 250, 0.2));
|
||||||
|
color: #c084fc;
|
||||||
|
border-color: rgba(167, 139, 250, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]):not([data-theme="white"]) .plan-tag {
|
||||||
|
background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(96, 165, 250, 0.2));
|
||||||
|
color: #c084fc;
|
||||||
|
border-color: rgba(167, 139, 250, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.brand-subtitle {
|
.brand-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -587,7 +663,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
<div>
|
<div>
|
||||||
<div class="brand-title">
|
<div class="brand-title">
|
||||||
Command Code 配额
|
Command Code 配额
|
||||||
<span class="version-tag">v0.1.0</span>
|
<span class="version-tag">v0.2.2</span>
|
||||||
|
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-subtitle">CLIProxyAPI 实时限额与 Credits 用量监控</div>
|
<div class="brand-subtitle">CLIProxyAPI 实时限额与 Credits 用量监控</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -676,6 +753,37 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
|
|
||||||
<!-- Double Window Limits -->
|
<!-- Double Window Limits -->
|
||||||
<div class="quota-section">
|
<div class="quota-section">
|
||||||
|
<!-- Monthly Window -->
|
||||||
|
<div id="cardMonthly" class="quota-card">
|
||||||
|
<div class="quota-card-header">
|
||||||
|
<div>
|
||||||
|
<span class="quota-tag" style="color:#f59e0b; background:rgba(245,158,11,0.12)">账单周期</span>
|
||||||
|
<div class="quota-name">月度额度 (Monthly Window)</div>
|
||||||
|
</div>
|
||||||
|
<div id="badgeMonthly" class="quota-percent-badge">- %</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quota-stats-row">
|
||||||
|
<div>
|
||||||
|
<span id="usedMonthly" class="quota-usage-num">-</span>
|
||||||
|
<span id="capMonthly" class="quota-cap-num">/ -</span>
|
||||||
|
</div>
|
||||||
|
<div>剩余可用: <strong id="remainMonthly">-</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-track">
|
||||||
|
<div id="barMonthly" class="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reset-box">
|
||||||
|
<div class="reset-label">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||||
|
<span>账单周期重置</span>
|
||||||
|
</div>
|
||||||
|
<div id="timerMonthly" class="countdown-timer">--:--:--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 5-Hour Window -->
|
<!-- 5-Hour Window -->
|
||||||
<div id="cardFiveHour" class="quota-card">
|
<div id="cardFiveHour" class="quota-card">
|
||||||
<div class="quota-card-header">
|
<div class="quota-card-header">
|
||||||
@@ -765,11 +873,20 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
const alertMsg = document.getElementById("alertMsg");
|
const alertMsg = document.getElementById("alertMsg");
|
||||||
const statusBadge = document.getElementById("statusBadge");
|
const statusBadge = document.getElementById("statusBadge");
|
||||||
const statusText = document.getElementById("statusText");
|
const statusText = document.getElementById("statusText");
|
||||||
|
const planBadge = document.getElementById("planBadge");
|
||||||
|
|
||||||
const valMonthlyCredits = document.getElementById("valMonthlyCredits");
|
const valMonthlyCredits = document.getElementById("valMonthlyCredits");
|
||||||
const valOpensourceCredits = document.getElementById("valOpensourceCredits");
|
const valOpensourceCredits = document.getElementById("valOpensourceCredits");
|
||||||
const valTotalCredits = document.getElementById("valTotalCredits");
|
const valTotalCredits = document.getElementById("valTotalCredits");
|
||||||
|
|
||||||
|
const cardMonthly = document.getElementById("cardMonthly");
|
||||||
|
const badgeMonthly = document.getElementById("badgeMonthly");
|
||||||
|
const usedMonthly = document.getElementById("usedMonthly");
|
||||||
|
const capMonthly = document.getElementById("capMonthly");
|
||||||
|
const remainMonthly = document.getElementById("remainMonthly");
|
||||||
|
const barMonthly = document.getElementById("barMonthly");
|
||||||
|
const timerMonthly = document.getElementById("timerMonthly");
|
||||||
|
|
||||||
const cardFiveHour = document.getElementById("cardFiveHour");
|
const cardFiveHour = document.getElementById("cardFiveHour");
|
||||||
const badgeFiveHour = document.getElementById("badgeFiveHour");
|
const badgeFiveHour = document.getElementById("badgeFiveHour");
|
||||||
const usedFiveHour = document.getElementById("usedFiveHour");
|
const usedFiveHour = document.getElementById("usedFiveHour");
|
||||||
@@ -787,6 +904,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
const timerWeekly = document.getElementById("timerWeekly");
|
const timerWeekly = document.getElementById("timerWeekly");
|
||||||
const lastUpdated = document.getElementById("lastUpdated");
|
const lastUpdated = document.getElementById("lastUpdated");
|
||||||
|
|
||||||
|
let monthlyTargetTime = null;
|
||||||
let fiveHourTargetTime = null;
|
let fiveHourTargetTime = null;
|
||||||
let weeklyTargetTime = null;
|
let weeklyTargetTime = null;
|
||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
@@ -814,9 +932,15 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
alertBox.className = "alert";
|
alertBox.className = "alert";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num) {
|
// Command Code 额度按美元(USD)计价:金额统一加 $ 前缀,按美元格式输出(千分位 + 两位小数)
|
||||||
if (num === null || num === undefined || isNaN(num)) return "0";
|
function formatUSD(num) {
|
||||||
return Number(num).toLocaleString(undefined, { maximumFractionDigits: 2 });
|
if (num === null || num === undefined || isNaN(num)) return "$0.00";
|
||||||
|
return Number(num).toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCountdown(targetDate) {
|
function formatCountdown(targetDate) {
|
||||||
@@ -842,6 +966,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateTimers() {
|
function updateTimers() {
|
||||||
|
if (monthlyTargetTime) {
|
||||||
|
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
|
||||||
|
}
|
||||||
if (fiveHourTargetTime) {
|
if (fiveHourTargetTime) {
|
||||||
timerFiveHour.textContent = formatCountdown(fiveHourTargetTime);
|
timerFiveHour.textContent = formatCountdown(fiveHourTargetTime);
|
||||||
}
|
}
|
||||||
@@ -855,18 +982,42 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
const credits = data.credits || (data.data && data.data.credits) || {};
|
const credits = data.credits || (data.data && data.data.credits) || {};
|
||||||
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
|
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
|
||||||
|
|
||||||
|
// Plan
|
||||||
|
const plan = data.plan || (data.data && data.data.plan);
|
||||||
|
if (plan) {
|
||||||
|
const planName = plan.name || (typeof plan === "string" ? plan : "Unknown");
|
||||||
|
planBadge.textContent = "Plan: " + planName;
|
||||||
|
planBadge.style.display = "inline-flex";
|
||||||
|
} else {
|
||||||
|
planBadge.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
// Credits
|
// Credits
|
||||||
valMonthlyCredits.textContent = formatNumber(credits.monthly_credits);
|
valMonthlyCredits.textContent = formatUSD(credits.monthly_credits);
|
||||||
valOpensourceCredits.textContent = formatNumber(credits.opensource_monthly_credits);
|
valOpensourceCredits.textContent = formatUSD(credits.opensource_monthly_credits);
|
||||||
valTotalCredits.textContent = formatNumber(credits.total_credits);
|
valTotalCredits.textContent = formatUSD(credits.total_credits);
|
||||||
|
|
||||||
|
// Monthly (billing period)
|
||||||
|
const monthly = limits.monthly || {};
|
||||||
|
const pMonth = Math.min(100, Math.max(0, monthly.percentage || 0));
|
||||||
|
badgeMonthly.textContent = pMonth.toFixed(1) + "%";
|
||||||
|
usedMonthly.textContent = formatUSD(monthly.used);
|
||||||
|
capMonthly.textContent = "/ " + formatUSD(monthly.cap);
|
||||||
|
remainMonthly.textContent = formatUSD(monthly.remaining);
|
||||||
|
barMonthly.style.width = pMonth + "%";
|
||||||
|
|
||||||
|
barMonthly.className = "progress-bar" + (pMonth >= 90 || monthly.exceeded ? " danger" : pMonth >= 70 ? " warning" : "");
|
||||||
|
cardMonthly.className = "quota-card" + (monthly.exceeded ? " is-exceeded" : "");
|
||||||
|
monthlyTargetTime = null;
|
||||||
|
timerMonthly.textContent = "账单周期";
|
||||||
|
|
||||||
// Five Hour
|
// Five Hour
|
||||||
const fiveHour = limits.five_hour || {};
|
const fiveHour = limits.five_hour || {};
|
||||||
const pFive = Math.min(100, Math.max(0, fiveHour.percentage || 0));
|
const pFive = Math.min(100, Math.max(0, fiveHour.percentage || 0));
|
||||||
badgeFiveHour.textContent = pFive.toFixed(1) + "%";
|
badgeFiveHour.textContent = pFive.toFixed(1) + "%";
|
||||||
usedFiveHour.textContent = formatNumber(fiveHour.used);
|
usedFiveHour.textContent = formatUSD(fiveHour.used);
|
||||||
capFiveHour.textContent = "/ " + formatNumber(fiveHour.cap);
|
capFiveHour.textContent = "/ " + formatUSD(fiveHour.cap);
|
||||||
remainFiveHour.textContent = formatNumber(fiveHour.remaining);
|
remainFiveHour.textContent = formatUSD(fiveHour.remaining);
|
||||||
barFiveHour.style.width = pFive + "%";
|
barFiveHour.style.width = pFive + "%";
|
||||||
|
|
||||||
barFiveHour.className = "progress-bar" + (pFive >= 90 || fiveHour.exceeded ? " danger" : pFive >= 70 ? " warning" : "");
|
barFiveHour.className = "progress-bar" + (pFive >= 90 || fiveHour.exceeded ? " danger" : pFive >= 70 ? " warning" : "");
|
||||||
@@ -884,9 +1035,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
const weekly = limits.weekly || {};
|
const weekly = limits.weekly || {};
|
||||||
const pWeek = Math.min(100, Math.max(0, weekly.percentage || 0));
|
const pWeek = Math.min(100, Math.max(0, weekly.percentage || 0));
|
||||||
badgeWeekly.textContent = pWeek.toFixed(1) + "%";
|
badgeWeekly.textContent = pWeek.toFixed(1) + "%";
|
||||||
usedWeekly.textContent = formatNumber(weekly.used);
|
usedWeekly.textContent = formatUSD(weekly.used);
|
||||||
capWeekly.textContent = "/ " + formatNumber(weekly.cap);
|
capWeekly.textContent = "/ " + formatUSD(weekly.cap);
|
||||||
remainWeekly.textContent = formatNumber(weekly.remaining);
|
remainWeekly.textContent = formatUSD(weekly.remaining);
|
||||||
barWeekly.style.width = pWeek + "%";
|
barWeekly.style.width = pWeek + "%";
|
||||||
|
|
||||||
barWeekly.className = "progress-bar" + (pWeek >= 90 || weekly.exceeded ? " danger" : pWeek >= 70 ? " warning" : "");
|
barWeekly.className = "progress-bar" + (pWeek >= 90 || weekly.exceeded ? " danger" : pWeek >= 70 ? " warning" : "");
|
||||||
@@ -901,10 +1052,10 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Overall Status
|
// Overall Status
|
||||||
if (fiveHour.exceeded || weekly.exceeded) {
|
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
|
||||||
statusBadge.className = "status-badge exceeded";
|
statusBadge.className = "status-badge exceeded";
|
||||||
statusText.textContent = "已达限额 (Exceeded)";
|
statusText.textContent = "已达限额 (Exceeded)";
|
||||||
} else if (pFive >= 80 || pWeek >= 80) {
|
} else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) {
|
||||||
statusBadge.className = "status-badge warning";
|
statusBadge.className = "status-badge warning";
|
||||||
statusText.textContent = "配额紧张 (Warning)";
|
statusText.textContent = "配额紧张 (Warning)";
|
||||||
} else {
|
} else {
|
||||||
@@ -1005,6 +1156,71 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
|||||||
inputMgmtKey.value = initKey;
|
inputMgmtKey.value = initKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncTheme() {
|
||||||
|
let themeSetting = null;
|
||||||
|
|
||||||
|
// 1. Try reading from parent window (if same-origin iframe)
|
||||||
|
try {
|
||||||
|
if (window.parent && window.parent !== window && window.parent.document) {
|
||||||
|
const parentTheme = window.parent.document.documentElement.getAttribute("data-theme");
|
||||||
|
if (parentTheme) {
|
||||||
|
themeSetting = parentTheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 2. Try reading from localStorage['cli-proxy-theme']
|
||||||
|
if (!themeSetting) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("cli-proxy-theme");
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed && parsed.state) {
|
||||||
|
if (parsed.state.theme) {
|
||||||
|
themeSetting = parsed.state.theme;
|
||||||
|
}
|
||||||
|
if (parsed.state.resolvedTheme && themeSetting === "auto") {
|
||||||
|
themeSetting = parsed.state.resolvedTheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to own html attribute
|
||||||
|
if (!themeSetting) {
|
||||||
|
themeSetting = document.documentElement.getAttribute("data-theme");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme to documentElement
|
||||||
|
const docEl = document.documentElement;
|
||||||
|
if (themeSetting === "dark") {
|
||||||
|
docEl.setAttribute("data-theme", "dark");
|
||||||
|
} else if (themeSetting === "white" || themeSetting === "light") {
|
||||||
|
docEl.setAttribute("data-theme", themeSetting);
|
||||||
|
} else {
|
||||||
|
// auto or unset: check system preference
|
||||||
|
const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
if (isDark) {
|
||||||
|
docEl.setAttribute("data-theme", "dark");
|
||||||
|
} else {
|
||||||
|
docEl.setAttribute("data-theme", "light");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize theme and sync listeners
|
||||||
|
syncTheme();
|
||||||
|
window.addEventListener("storage", (e) => {
|
||||||
|
if (e.key === "cli-proxy-theme") {
|
||||||
|
syncTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", syncTheme);
|
||||||
|
}
|
||||||
|
setInterval(syncTheme, 2000);
|
||||||
|
|
||||||
if (!timerInterval) {
|
if (!timerInterval) {
|
||||||
timerInterval = setInterval(updateTimers, 1000);
|
timerInterval = setInterval(updateTimers, 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-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,6 +241,7 @@ 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"`
|
||||||
|
|||||||
+122
-6
@@ -42,8 +42,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,7 +53,7 @@ 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)
|
||||||
|
|
||||||
// 1. Try host.http.do if hostCaller is configured
|
// 1. Try host.http.do if hostCaller is configured
|
||||||
@@ -63,7 +64,7 @@ func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCall
|
|||||||
Headers: map[string][]string{
|
Headers: map[string][]string{
|
||||||
"Cookie": {cookieValue},
|
"Cookie": {cookieValue},
|
||||||
"Accept": {"application/json"},
|
"Accept": {"application/json"},
|
||||||
"User-Agent": {"cliproxy-plugin-commandcode/0.1.0"},
|
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
|
||||||
},
|
},
|
||||||
HostCallbackID: hostCallbackID,
|
HostCallbackID: hostCallbackID,
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@ func FetchCreditsRaw(ctx context.Context, apiBase, sessionToken string, hostCall
|
|||||||
}
|
}
|
||||||
httpReq.Header.Set("Cookie", cookieValue)
|
httpReq.Header.Set("Cookie", cookieValue)
|
||||||
httpReq.Header.Set("Accept", "application/json")
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
httpReq.Header.Set("User-Agent", "cliproxy-plugin-commandcode/0.1.0")
|
httpReq.Header.Set("User-Agent", fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion))
|
||||||
|
|
||||||
res, errDo := defaultHTTPClient.Do(httpReq)
|
res, errDo := defaultHTTPClient.Do(httpReq)
|
||||||
if errDo != nil {
|
if errDo != nil {
|
||||||
@@ -120,8 +121,19 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +161,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 +176,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 +184,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 +269,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
|
||||||
|
|||||||
+158
-2
@@ -3,6 +3,7 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -81,7 +82,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 +174,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 +233,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 +279,100 @@ 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user