From d41f0a24e2b7537e5b11bb201d9de460dbe6409e Mon Sep 17 00:00:00 2001 From: Elliott Linder Date: Wed, 30 Sep 2026 11:27:37 +0200 Subject: [PATCH 1/8] Use CLIProxyAPI v8 native quotas and readable OpenCode Go accounts --- README.md | 30 +- RELEASE_NOTES.md | 32 +- go.mod | 2 +- go.sum | 4 +- internal/adapter/chatcompletions/request.go | 2 +- .../adapter/chatcompletions/request_test.go | 2 +- internal/adapter/messages/request.go | 2 +- internal/adapter/messages/request_test.go | 2 +- internal/adapter/responses/request.go | 2 +- internal/adapter/responses/request_test.go | 2 +- internal/catalog/catalog.go | 2 +- internal/catalog/catalog_test.go | 2 +- internal/config/config.go | 4 +- internal/errclass/errclass.go | 2 +- internal/errclass/errclass_test.go | 2 +- internal/plugin/auth.go | 31 +- internal/plugin/auth_test.go | 4 +- internal/plugin/executor.go | 4 +- internal/plugin/executor_test.go | 4 +- internal/plugin/hostbridge.go | 4 +- internal/plugin/hostbridge_stream_test.go | 4 +- internal/plugin/hostbridge_test.go | 4 +- internal/plugin/integration_test.go | 4 +- internal/plugin/plugin.go | 136 +++++---- internal/plugin/plugin_test.go | 20 +- internal/plugin/quota.go | 152 +++------ internal/plugin/quota_test.go | 288 ++++++------------ internal/plugin/refresh_test.go | 4 +- internal/plugin/session_test.go | 2 +- internal/thinking/thinking.go | 10 +- internal/thinking/thinking_test.go | 4 +- main.go | 2 +- resources/embed.go | 6 - resources/quota_page.html | 259 ---------------- 34 files changed, 343 insertions(+), 691 deletions(-) delete mode 100644 resources/embed.go delete mode 100644 resources/quota_page.html diff --git a/README.md b/README.md index b62a393..5377c65 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,12 @@ This plugin exposes OpenCode Go as a single provider (`opencode-go`) backed by a - **Thinking & Reasoning Support**: Maps reasoning effort across supported client and upstream formats. - **Dynamic Catalog Discovery**: Fetches remote model catalogs with local fallback and custom route overrides. - **Multi-Key Auth Scheduling**: Pools multiple API keys with CLIProxyAPI's native scheduler for rotation, retries, and error cooldowns across all protocols. -- **OpenCode Go Quota Page**: Management Center includes a separate `OpenCode Go Quota` page. Page load lists credentials without contacting OpenCode; each card is refreshed manually and independently, and quota values do not affect routing or CPA's native quota page. +- **Native quotas**: Rolling, weekly, and monthly quotas use CLIProxyAPI’s generic quota endpoints and the selected credential. The separate plugin page is removed. ## Requirements -- **CLIProxyAPI**: `v7.2.138+` -- **Go Toolchain**: Go 1.24+ (with CGO enabled for C-shared build mode) +- **CLIProxyAPI**: `v8.0.0+` +- **Go Toolchain**: Go 1.26.7+ (with CGO enabled for C-shared build mode) ## Build @@ -81,6 +81,7 @@ plugins: # OpenCode Go API keys (at least one required). Supports ${ENV_VAR} expansion. api-keys: - value: "sk-opencode-key-1" + name: "Personal" # optional account label - value: "sk-opencode-key-2" - value: "${OPENCODE_GO_API_KEY}" @@ -111,7 +112,7 @@ plugins: | Option | Type | Default | Description | |---|---|---|---| -| `api-keys` | `[]object` | *(Required)* | List of API keys (`- value: "..."`). Supports `${ENV_VAR}` expansion. Duplicates and empty values are rejected. | +| `api-keys` | `[]object` | *(Required)* | List of API keys (`- value: "..."`, optional `name: "Personal"`). Supports `${ENV_VAR}` expansion. Duplicates and empty values are rejected. | | `base-url` | `string` | `https://opencode.ai/zen/go/v1` | Upstream base URL. Must be valid HTTPS (or HTTP if `allow-http: true`) without query parameters, fragments, or userinfo. | | `catalog-url` | `string` | `{base-url}/models` | Full URL for catalog discovery. Defaults to `{base-url}/models`. | | `model-prefix.enabled` | `bool` | `true` | When `true`, client-facing model names use `/`. When `false`, uses bare model IDs. | @@ -138,3 +139,24 @@ go test ./... -cover # Run linter / vetting go vet ./... ``` + +## Account names and quotas + +Set an optional `name` on each `api-keys` entry, alongside `value`. +For example, use `name: Personal` or `name: Work`. Without a name, one key +uses **OpenCode Go**; multiple keys use **OpenCode Go 1**, **OpenCode Go 2**, and subsequent numbers. +Default numbers follow configuration order. Explicit names remain stable when keys are reordered. +The usage response contains no email or account identity. The plugin does not infer an email from a key. + +New credential files have readable names. Existing auth IDs remain unchanged, and +legacy generated labels are replaced when parsed. Existing custom labels are preserved +unless configuration specifies a name. Existing filenames are retained automatically; +stop CLIProxyAPI before renaming an old file to a readable `.json` filename, and keep +its JSON `id` and all other fields unchanged. Back up the file first. + +Discover support with `GET /v0/management/quota/providers`, then call +`POST /v0/management/quota/fetch` with `{"auth_index":""}`. +These endpoints require the CLIProxyAPI management key. The normalized response contains +`subscription.plan`, `groups[].buckets[].window`, `remainingFraction`, and `resetTime`. +Missing windows are omitted. Invalid readings return an error, not an invented zero. +Quota reset is unsupported. The old plugin quota route and resource page are removed. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 52b3f0f..c5379a2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,18 +1,26 @@ ## What's Changed -### Features +- Support CLIProxyAPI v8 and its generic plugin quota API. +- Return rolling, weekly, and monthly quotas for the credential selected by CLIProxyAPI. +- Advertise the quota provider as **OpenCode Go**. +- Remove the separate OpenCode Go quota page and custom management routes. +- Accept an optional `name` for each API key. +- Use readable account labels and filenames instead of key digests. +- Preserve existing auth IDs, custom labels, filenames, and host metadata. +- Prevent duplicate auth records at cold start when readable filenames exist. +- Reject invalid quota values and redact upstream request errors. -- Expose `ConfigFields` in plugin registration metadata, enabling interactive configuration editing in CLIProxyAPI Management Center for all plugin settings (`api-keys`, `base-url`, `catalog-url`, `model-prefix`, `catalog`, `protocols`, `route-overrides`, `request-timeout`, `max-response-bytes`, `allow-http`). -- Add "Refresh All" button in quota page. +## Upgrade -### Bug Fixes +1. Upgrade CLIProxyAPI to v8.0.0 or later. +2. Replace the plugin binary with the new build. +3. Restart CLIProxyAPI. -- Normalize `role: "developer"` to `role: "system"` in the chat-completions adapter, preventing DeepSeek-backed models from rejecting valid client requests with HTTP 400 ([#5](https://github.com/massiveits/opencode-go-cliproxyapi/issues/5)). Thanks to [@zlwu](https://github.com/zlwu). +The generic endpoints are `GET /v0/management/quota/providers` and +`POST /v0/management/quota/fetch`. Both require the management key. +Quota reset is unsupported. A management dashboard must support the generic +quota API to show plugin refresh controls. Backend support alone does not +add those controls to older dashboard builds. -## Upgrade Notes - -- Replace the old plugin binary with the new release binary. -- Restart CLIProxyAPI after replacing the plugin. -- Hard-refresh Management Center if the plugin page looks stale. - -**Full Changelog**: https://github.com/massiveits/opencode-go-cliproxyapi/compare/v0.1.8...v0.1.9 \ No newline at end of file +Existing credential files keep their names and stable IDs. Set `name:` on an +API key to choose its display label. New files use readable names. diff --git a/go.mod b/go.mod index 69103b2..4043b3e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module opencode-go-cliproxyapi go 1.26.7 require ( - github.com/router-for-me/CLIProxyAPI/v7 v7.2.138 + github.com/router-for-me/CLIProxyAPI/v8 v8.0.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index db1f7e0..7fcb686 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/router-for-me/CLIProxyAPI/v7 v7.2.138 h1:RbMi2wh0cOzvTq3O1vooSHz9FFvCvP5yHd3Aganjk5g= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.138/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= +github.com/router-for-me/CLIProxyAPI/v8 v8.0.0 h1:UfV1dhI+319cCquIKqY3c5VpJCP81Y052PPKCIhx0l0= +github.com/router-for-me/CLIProxyAPI/v8 v8.0.0/go.mod h1:j1Mtcj0TrC66O1JwbSBB03lY3uYPx4J7yHNvU6nDBCo= 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= diff --git a/internal/adapter/chatcompletions/request.go b/internal/adapter/chatcompletions/request.go index b084d3e..9ebf32b 100644 --- a/internal/adapter/chatcompletions/request.go +++ b/internal/adapter/chatcompletions/request.go @@ -9,7 +9,7 @@ import ( "net/http" "strings" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/catalog" diff --git a/internal/adapter/chatcompletions/request_test.go b/internal/adapter/chatcompletions/request_test.go index 41c2e88..d8ecda3 100644 --- a/internal/adapter/chatcompletions/request_test.go +++ b/internal/adapter/chatcompletions/request_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/errclass" ) diff --git a/internal/adapter/messages/request.go b/internal/adapter/messages/request.go index c35d358..de63d87 100644 --- a/internal/adapter/messages/request.go +++ b/internal/adapter/messages/request.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/errclass" diff --git a/internal/adapter/messages/request_test.go b/internal/adapter/messages/request_test.go index 34da5b8..e9e7c41 100644 --- a/internal/adapter/messages/request_test.go +++ b/internal/adapter/messages/request_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/errclass" diff --git a/internal/adapter/responses/request.go b/internal/adapter/responses/request.go index c5843d8..541dc4e 100644 --- a/internal/adapter/responses/request.go +++ b/internal/adapter/responses/request.go @@ -15,7 +15,7 @@ import ( "slices" "strings" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/catalog" diff --git a/internal/adapter/responses/request_test.go b/internal/adapter/responses/request_test.go index dbea2fc..fd5fe7c 100644 --- a/internal/adapter/responses/request_test.go +++ b/internal/adapter/responses/request_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/errclass" diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 3118a98..2d2a99b 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -18,7 +18,7 @@ import ( "strings" "sync" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/config" ) diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index c666d1e..36cea66 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -10,7 +10,7 @@ import ( "sync" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/config" ) diff --git a/internal/config/config.go b/internal/config/config.go index dec6629..331b14b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ type ModelPrefix struct { } type APIKey struct { + Name string Value string } @@ -82,6 +83,7 @@ type rawPrefix struct { } type rawKey struct { + Name string `yaml:"name"` Value string `yaml:"value"` } @@ -114,7 +116,7 @@ func Load(yamlBytes []byte) (Config, error) { } keys := make([]APIKey, len(raw.APIKeys)) for i, k := range raw.APIKeys { - keys[i] = APIKey{Value: os.ExpandEnv(k.Value)} + keys[i] = APIKey{Value: os.ExpandEnv(k.Value), Name: strings.TrimSpace(k.Name)} } refreshInterval, err := parseDuration("catalog.refresh-interval", raw.Catalog.RefreshInterval, DefaultRefreshInterval) if err != nil { diff --git a/internal/errclass/errclass.go b/internal/errclass/errclass.go index 47e278d..cf72fdd 100644 --- a/internal/errclass/errclass.go +++ b/internal/errclass/errclass.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" ) // Class is a stable error-category identifier (FR-009) used as the diff --git a/internal/errclass/errclass_test.go b/internal/errclass/errclass_test.go index 100f6bf..a8291c5 100644 --- a/internal/errclass/errclass_test.go +++ b/internal/errclass/errclass_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" ) func TestClassConstants(t *testing.T) { diff --git a/internal/plugin/auth.go b/internal/plugin/auth.go index 03b56b9..ec390ab 100644 --- a/internal/plugin/auth.go +++ b/internal/plugin/auth.go @@ -4,18 +4,19 @@ import ( "context" "encoding/json" "fmt" + "opencode-go-cliproxyapi/internal/config" "strings" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) -type authProvider struct{} +type authProvider struct{ cfg config.Config } var _ pluginapi.AuthProvider = authProvider{} func (authProvider) Identifier() string { return ProviderID } -func (authProvider) ParseAuth(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) { +func (p authProvider) ParseAuth(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) { debugTrace("auth parse request provider=%s file=%s raw_bytes=%d", req.Provider, req.FileName, len(req.RawJSON)) var raw struct { Type string `json:"type"` @@ -41,7 +42,7 @@ func (authProvider) ParseAuth(_ context.Context, req pluginapi.AuthParseRequest) } debugTrace("auth parse handled provider=%s file=%s id=%s api_key_present=%t api_key_length=%d", req.Provider, req.FileName, raw.ID, strings.TrimSpace(raw.APIKey) != "", len(raw.APIKey)) return pluginapi.AuthParseResponse{Handled: true, Auth: pluginapi.AuthData{ - Provider: ProviderID, ID: raw.ID, FileName: req.FileName, Label: raw.Label, StorageJSON: req.RawJSON, + Provider: ProviderID, ID: raw.ID, FileName: req.FileName, Label: accountLabel(p.cfg, raw.APIKey, raw.Label, 0), StorageJSON: req.RawJSON, Attributes: map[string]string{"api_key": raw.APIKey}, }}, nil } @@ -66,3 +67,25 @@ func mapKeys[T any](values map[string]T) []string { } return keys } + +// Labels are presentation only. Stable auth IDs and stored host metadata are preserved. +func accountLabel(cfg config.Config, key, existing string, fallbackIndex int) string { + index := fallbackIndex + for i, entry := range cfg.APIKeys { + if entry.Value == key { + if name := strings.TrimSpace(entry.Name); name != "" { + return name + } + index = i + break + } + } + label := strings.TrimSpace(existing) + if label != "" && !strings.HasPrefix(label, "OpenCode Go credential ") && !strings.HasPrefix(label, "opencode-go-key-") { + return label + } + if len(cfg.APIKeys) > 1 { + return fmt.Sprintf("OpenCode Go %d", index+1) + } + return "OpenCode Go" +} diff --git a/internal/plugin/auth_test.go b/internal/plugin/auth_test.go index 11d2b2e..68ef67e 100644 --- a/internal/plugin/auth_test.go +++ b/internal/plugin/auth_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) func TestAuthProviderParseAndRefresh(t *testing.T) { diff --git a/internal/plugin/executor.go b/internal/plugin/executor.go index 8c15095..6ad1666 100644 --- a/internal/plugin/executor.go +++ b/internal/plugin/executor.go @@ -17,8 +17,8 @@ import ( "sync/atomic" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/chatcompletions" "opencode-go-cliproxyapi/internal/adapter/messages" diff --git a/internal/plugin/executor_test.go b/internal/plugin/executor_test.go index cedaa5a..e089110 100644 --- a/internal/plugin/executor_test.go +++ b/internal/plugin/executor_test.go @@ -12,8 +12,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/catalog" diff --git a/internal/plugin/hostbridge.go b/internal/plugin/hostbridge.go index 0c0492b..7fa8c38 100644 --- a/internal/plugin/hostbridge.go +++ b/internal/plugin/hostbridge.go @@ -12,8 +12,8 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) // RawCaller performs one host callback round-trip: method name plus JSON diff --git a/internal/plugin/hostbridge_stream_test.go b/internal/plugin/hostbridge_stream_test.go index 9eb9422..631a98e 100644 --- a/internal/plugin/hostbridge_stream_test.go +++ b/internal/plugin/hostbridge_stream_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) func decodePayload(t *testing.T, c capturedCall) map[string]any { diff --git a/internal/plugin/hostbridge_test.go b/internal/plugin/hostbridge_test.go index 909c4d1..6dbf2eb 100644 --- a/internal/plugin/hostbridge_test.go +++ b/internal/plugin/hostbridge_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) // TestBridgeCallTimeout pins the F-watchdog contract: a black-holed host diff --git a/internal/plugin/integration_test.go b/internal/plugin/integration_test.go index c496b4d..2c60cf3 100644 --- a/internal/plugin/integration_test.go +++ b/internal/plugin/integration_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/config" ) diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 4f46faa..0cc1c36 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -6,12 +6,15 @@ import ( "encoding/hex" "encoding/json" "fmt" + "os" + "path/filepath" "strings" "sync" "time" + "unicode" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/catalog" "opencode-go-cliproxyapi/internal/config" @@ -24,7 +27,7 @@ const ProviderID = "opencode-go" // pluginName / pluginVersion are reported in registration metadata. const ( pluginName = "opencode-go-cliproxyapi" - pluginVersion = "0.1.9" + pluginVersion = "0.2.0" ) // githubRepoURL satisfies the host's validPlugin gate (host.go @@ -92,7 +95,10 @@ func (m *Manager) HandleCall(method string, request []byte) (resp []byte, err er if json.Unmarshal(request, &req) != nil { return ErrEnvelope("invalid_request", "malformed auth parse request body"), nil } - resp, err := (authProvider{}).ParseAuth(context.Background(), req) + m.mu.RLock() + cfg := m.cfg + m.mu.RUnlock() + resp, err := (authProvider{cfg: cfg}).ParseAuth(context.Background(), req) if err != nil { return ErrEnvelope("auth_failure", err.Error()), nil } @@ -124,10 +130,22 @@ func (m *Manager) HandleCall(method string, request []byte) (resp []byte, err er return ErrEnvelope("auth_failure", err.Error()), nil } return okEnvelope(resp), nil - case pluginabi.MethodManagementRegister: - return m.registerManagement(request) - case pluginabi.MethodManagementHandle: - return m.handleManagement(request) + case pluginabi.MethodQuotaIdentifier: + return okEnvelope(map[string]string{"identifier": ProviderID}), nil + case pluginabi.MethodQuotaDescribe: + return okEnvelope(pluginapi.QuotaDescribeResponse{SupportedProviders: []string{ProviderID}, DisplayName: "OpenCode Go"}), nil + case pluginabi.MethodQuotaFetch: + var req pluginapi.QuotaFetchRequest + if json.Unmarshal(request, &req) != nil { + return ErrEnvelope("invalid_request", "malformed quota request"), nil + } + result, err := m.FetchQuota(context.Background(), req) + if err != nil { + return ErrEnvelope("quota_failure", err.Error()), nil + } + return okEnvelope(result), nil + case pluginabi.MethodQuotaReset: + return ErrEnvelope("unsupported", "OpenCode Go does not support quota resets"), nil case pluginabi.MethodExecutorIdentifier: return okEnvelope(map[string]string{"identifier": ProviderID}), nil case pluginabi.MethodExecutorCountTokens: @@ -159,6 +177,7 @@ type capabilities struct { ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope,omitempty"` ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"` + QuotaProvider bool `json:"quota_provider"` ManagementAPI bool `json:"management_api"` } @@ -173,7 +192,7 @@ func pluginConfigFields() []pluginapi.ConfigField { { Name: "api-keys", Type: pluginapi.ConfigFieldTypeArray, - Description: "List of OpenCode Go API keys (`- value: ...`). Supports ${ENV_VAR} expansion.", + Description: "List of OpenCode Go API keys (`- value: ...`, optional `name: Personal`). Supports ${ENV_VAR} expansion.", }, { Name: "base-url", @@ -241,58 +260,11 @@ func registrationEnvelope() []byte { ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth, ExecutorInputFormats: formats, ExecutorOutputFormats: formats, - ManagementAPI: true, + QuotaProvider: true, }, }) } -func (m *Manager) registerManagement(request []byte) ([]byte, error) { - var req struct { - Plugin pluginapi.Metadata `json:"Plugin"` - BasePath string `json:"BasePath"` - ResourceBasePath string `json:"ResourceBasePath"` - } - if err := json.Unmarshal(request, &req); err != nil { - return ErrEnvelope("invalid_request", "malformed management registration request body"), nil - } - return okEnvelope(struct { - Routes []struct { - Method string `json:"method"` - Path string `json:"path"` - } `json:"routes"` - Resources []struct { - Path string `json:"path"` - Menu string `json:"menu"` - Description string `json:"description"` - } `json:"resources"` - }{ - Routes: []struct { - Method string `json:"method"` - Path string `json:"path"` - }{{Method: "POST", Path: "/plugins/" + pluginName + "/quota-usage"}}, - Resources: []struct { - Path string `json:"path"` - Menu string `json:"menu"` - Description string `json:"description"` - }{{Path: "/quota", Menu: "OpenCode Go Quota", Description: "View OpenCode Go quota windows."}}, - }), nil -} - -func (m *Manager) handleManagement(request []byte) ([]byte, error) { - var req struct { - pluginapi.ManagementRequest - HostCallbackID string `json:"host_callback_id,omitempty"` - } - if err := json.Unmarshal(request, &req); err != nil { - return ErrEnvelope("invalid_request", "malformed management request body"), nil - } - resp, err := m.HandleManagement(context.Background(), req.ManagementRequest) - if err != nil { - return ErrEnvelope("management_failure", err.Error()), nil - } - return okEnvelope(resp), nil -} - // handleLifecycle implements plugin.register / plugin.reconfigure: load // config, refresh the catalog once, publish state, restart the refresh // loop. Registration succeeds even when the initial refresh fails (FR-002). @@ -372,6 +344,26 @@ func (m *Manager) materializeAuthRecords(ctx context.Context, cfg config.Config) } existing := make(map[string]struct{}, len(entries)*2) for _, entry := range entries { + // Before the auth manager is ready, v8 lists disk files without IDs. + // Recover identity from those host-reported paths to avoid duplicates + // when a credential has a readable filename. + if (entry.Provider == ProviderID || entry.Type == ProviderID) && !strings.HasPrefix(entry.ID, "opencode-go-key-") { + if !filepath.IsAbs(entry.Path) { + return fmt.Errorf("existing auth record has no absolute path") + } + raw, err := os.ReadFile(entry.Path) + if err != nil { + return fmt.Errorf("cannot read existing plugin auth record") + } + var record struct { + APIKey string `json:"api_key"` + } + if json.Unmarshal(raw, &record) != nil || record.APIKey == "" { + return fmt.Errorf("existing plugin auth record is invalid") + } + digest := sha256.Sum256([]byte(record.APIKey)) + existing["opencode-go-key-"+hex.EncodeToString(digest[:])] = struct{}{} + } if name := strings.TrimSpace(entry.Name); name != "" { existing[name] = struct{}{} } @@ -379,7 +371,7 @@ func (m *Manager) materializeAuthRecords(ctx context.Context, cfg config.Config) existing[id] = struct{}{} } } - for _, key := range cfg.APIKeys { + for i, key := range cfg.APIKeys { digest := sha256.Sum256([]byte(key.Value)) hash := hex.EncodeToString(digest[:]) id := "opencode-go-key-" + hash @@ -390,13 +382,14 @@ func (m *Manager) materializeAuthRecords(ctx context.Context, cfg config.Config) if _, ok := existing[name]; ok { continue } + name = availableAuthName(accountLabel(cfg, key.Value, "", i), existing) record, err := json.Marshal(struct { Type string `json:"type"` ID string `json:"id"` Label string `json:"label"` APIKey string `json:"api_key"` }{ - Type: "opencode-go", ID: id, Label: "OpenCode Go credential " + hash, APIKey: key.Value, + Type: "opencode-go", ID: id, Label: accountLabel(cfg, key.Value, "", i), APIKey: key.Value, }) if err != nil { return fmt.Errorf("build auth record") @@ -571,3 +564,28 @@ func ErrEnvelope(code, message string) []byte { out, _ := json.Marshal(pluginabi.Envelope{OK: false, Error: &pluginabi.Error{Code: code, Message: message}}) return out } + +// Use a readable filename because management clients may show it instead of Label. +func availableAuthName(label string, existing map[string]struct{}) string { + base := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { + return r + } + return '-' + }, label) + base = strings.Trim(base, "-") + if base == "" { + base = "OpenCode-Go" + } + runes := []rune(base) + if len(runes) > 80 { + base = string(runes[:80]) + } + name := base + ".json" + for n := 2; ; n++ { + if _, exists := existing[name]; !exists { + return name + } + name = fmt.Sprintf("%s-%d.json", base, n) + } +} diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 24ad374..7e41ed4 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -15,8 +15,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/catalog" "opencode-go-cliproxyapi/internal/config" @@ -41,7 +41,7 @@ type fakeCaller struct { mu sync.Mutex calls []capturedCall responder func(method string, payload []byte) ([]byte, error) - authFiles map[string]struct{} + authFiles map[string]string } func (f *fakeCaller) call(method string, payload []byte) ([]byte, error) { @@ -63,9 +63,13 @@ func (f *fakeCaller) call(method string, payload []byte) ([]byte, error) { if json.Unmarshal(payload, &req) == nil && strings.TrimSpace(req.Name) != "" { f.mu.Lock() if f.authFiles == nil { - f.authFiles = make(map[string]struct{}) + f.authFiles = make(map[string]string) } - f.authFiles[req.Name] = struct{}{} + var record struct { + ID string `json:"id"` + } + _ = json.Unmarshal(req.JSON, &record) + f.authFiles[req.Name] = record.ID f.mu.Unlock() } } @@ -93,9 +97,9 @@ func hasExplicitAuthList(raw []byte) bool { func (f *fakeCaller) authListResponse() []byte { f.mu.Lock() files := make([]pluginapi.HostAuthFileEntry, 0, len(f.authFiles)) - for name := range f.authFiles { + for name, id := range f.authFiles { files = append(files, pluginapi.HostAuthFileEntry{ - ID: strings.TrimSuffix(name, ".json"), Name: name, Source: "file", Path: name, + ID: id, Name: name, Source: "file", Path: name, }) } f.mu.Unlock() @@ -550,7 +554,7 @@ func TestLifecycleMaterializesDeterministicAuthRecords(t *testing.T) { } hash := sha256.Sum256([]byte(record.APIKey)) wantHash := hex.EncodeToString(hash[:]) - if record.Type != ProviderID || record.ID != "opencode-go-key-"+wantHash || record.Label != "OpenCode Go credential "+wantHash || wire.Name != record.ID+".json" { + if record.Type != ProviderID || record.ID != "opencode-go-key-"+wantHash || !strings.HasPrefix(record.Label, "OpenCode Go ") || !strings.HasPrefix(wire.Name, "OpenCode-Go-") { t.Fatalf("record identity = %+v name=%q", record, wire.Name) } if record.APIKey == "" || strings.Contains(wire.Name, record.APIKey) || strings.Contains(record.ID, record.APIKey) { diff --git a/internal/plugin/quota.go b/internal/plugin/quota.go index cf069f4..d856c58 100644 --- a/internal/plugin/quota.go +++ b/internal/plugin/quota.go @@ -2,130 +2,78 @@ package plugin import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" + "math" "net/http" "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" - - "opencode-go-cliproxyapi/internal/config" - "opencode-go-cliproxyapi/resources" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) -type quotaWindow struct { - Status string `json:"status"` - Percent int `json:"percent"` - ResetsAt string `json:"resets_at"` -} - -type quotaUsage struct { - Rolling quotaWindow `json:"rolling"` - Weekly quotaWindow `json:"weekly"` - Monthly quotaWindow `json:"monthly"` -} - -type quotaCard struct { - KeyID string `json:"key_id"` - Label string `json:"label"` - Usage *quotaUsage `json:"usage,omitempty"` -} - -type quotaRequest struct { - KeyID string `json:"key_id"` -} - -type usageResponse struct { - Usage struct { - Rolling quotaUpstreamWindow `json:"rolling"` - Weekly quotaUpstreamWindow `json:"weekly"` - Monthly quotaUpstreamWindow `json:"monthly"` - } `json:"usage"` -} - type quotaUpstreamWindow struct { - Status string `json:"status"` - Percent int `json:"percent"` - ResetsAt string `json:"resetsAt"` -} - -func quotaIdentity(key string) (id, label string) { - digest := sha256.Sum256([]byte(key)) - hash := hex.EncodeToString(digest[:]) - return "opencode-go-key-" + hash, "OpenCode Go credential " + hash[:12] + Percent *float64 `json:"percent"` + ResetsAt string `json:"resetsAt"` } -func (m *Manager) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { - if req.Method == http.MethodGet && req.Path == "/v0/resource/plugins/"+pluginName+"/quota" { - return pluginapi.ManagementResponse{Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, Body: []byte(resources.QuotaPage)}, nil +// FetchQuota uses the credential selected by the host, never a default config key. +func (m *Manager) FetchQuota(ctx context.Context, req pluginapi.QuotaFetchRequest) (pluginapi.QuotaFetchResponse, error) { + empty := pluginapi.QuotaFetchResponse{} + if req.Provider != ProviderID { + return empty, fmt.Errorf("unsupported quota provider") } - if req.Method != http.MethodPost || req.Path != "/v0/management/plugins/"+pluginName+"/quota-usage" { - return pluginapi.ManagementResponse{StatusCode: http.StatusNotFound, Body: []byte(`{"error":"not found"}`)}, nil - } - var body quotaRequest - if len(req.Body) > 0 { - if err := json.Unmarshal(req.Body, &body); err != nil { - return pluginapi.ManagementResponse{StatusCode: http.StatusBadRequest, Body: []byte(`{"error":"invalid request"}`)}, nil - } + key := strings.TrimSpace(req.Attributes["api_key"]) + if key == "" { + return empty, fmt.Errorf("quota credential has no API key") } m.mu.RLock() baseURL, timeout := m.cfg.BaseURL, m.cfg.RequestTimeout - keys := append([]config.APIKey(nil), m.cfg.APIKeys...) m.mu.RUnlock() - if body.KeyID == "" { - cards := make([]quotaCard, 0, len(keys)) - for _, key := range keys { - id, label := quotaIdentity(key.Value) - cards = append(cards, quotaCard{KeyID: id, Label: label}) - } - return quotaJSON(quotaList{Cards: cards}) + if m.bridge == nil { + return empty, fmt.Errorf("quota bridge unavailable") } - for _, key := range keys { - id, label := quotaIdentity(key.Value) - if id != body.KeyID { - continue - } - usage, err := fetchQuota(ctx, m.bridge, baseURL, timeout, key.Value) - if err != nil { - return pluginapi.ManagementResponse{StatusCode: http.StatusBadGateway, Body: []byte(`{"error":"quota refresh failed"}`)}, nil - } - return quotaJSON(quotaCard{KeyID: id, Label: label, Usage: &usage}) + if timeout <= 0 || timeout > 20*time.Second { + timeout = 20 * time.Second } - return pluginapi.ManagementResponse{StatusCode: http.StatusNotFound, Body: []byte(`{"error":"unknown quota key"}`)}, nil -} - -type quotaList struct { - Cards []quotaCard `json:"cards"` -} - -func quotaJSON(v any) (pluginapi.ManagementResponse, error) { - body, err := json.Marshal(v) - if err != nil { - return pluginapi.ManagementResponse{}, fmt.Errorf("quota response encoding failed") + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + resp, err := m.bridge.Do(ctx, pluginapi.HTTPRequest{Method: http.MethodGet, URL: strings.TrimRight(baseURL, "/") + "/usage", Headers: http.Header{"Authorization": {"Bearer " + key}, "Accept": {"application/json"}}}) + if err != nil || resp.StatusCode != http.StatusOK { + return empty, fmt.Errorf("quota upstream request failed") } - return pluginapi.ManagementResponse{Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: body}, nil + return normalizeQuota(resp.Body) } -func fetchQuota(ctx context.Context, bridge *HostBridge, baseURL string, timeout time.Duration, key string) (quotaUsage, error) { - if bridge == nil { - return quotaUsage{}, fmt.Errorf("quota bridge unavailable") +func normalizeQuota(body []byte) (pluginapi.QuotaFetchResponse, error) { + var decoded struct { + Usage map[string]quotaUpstreamWindow `json:"usage"` } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - resp, err := bridge.Do(ctx, pluginapi.HTTPRequest{Method: http.MethodGet, URL: strings.TrimRight(baseURL, "/") + "/usage", Headers: http.Header{"Authorization": []string{"Bearer " + key}, "Accept": []string{"application/json"}}}) - if err != nil || resp.StatusCode != http.StatusOK { - return quotaUsage{}, fmt.Errorf("quota upstream request failed") + if json.Unmarshal(body, &decoded) != nil { + return pluginapi.QuotaFetchResponse{}, fmt.Errorf("quota response invalid") + } + buckets := []pluginapi.QuotaBucket{} + for _, name := range []string{"rolling", "weekly", "monthly"} { + window, exists := decoded.Usage[name] + if !exists || window.Percent == nil { + continue + } + percent := *window.Percent + if math.IsNaN(percent) || math.IsInf(percent, 0) || percent < 0 || percent > 100 { + return pluginapi.QuotaFetchResponse{}, fmt.Errorf("quota percentage invalid") + } + reset := "" + if window.ResetsAt != "" { + parsed, err := time.Parse(time.RFC3339Nano, window.ResetsAt) + if err != nil { + return pluginapi.QuotaFetchResponse{}, fmt.Errorf("quota reset time invalid") + } + reset = parsed.UTC().Format(time.RFC3339Nano) + } + buckets = append(buckets, pluginapi.QuotaBucket{Window: name, RemainingFraction: 1 - percent/100, ResetTime: reset}) } - var decoded usageResponse - if err := json.Unmarshal(resp.Body, &decoded); err != nil { - return quotaUsage{}, fmt.Errorf("quota response invalid") + if len(buckets) == 0 { + return pluginapi.QuotaFetchResponse{}, fmt.Errorf("quota response has no readings") } - return quotaUsage{ - Rolling: quotaWindow{Status: decoded.Usage.Rolling.Status, Percent: decoded.Usage.Rolling.Percent, ResetsAt: decoded.Usage.Rolling.ResetsAt}, - Weekly: quotaWindow{Status: decoded.Usage.Weekly.Status, Percent: decoded.Usage.Weekly.Percent, ResetsAt: decoded.Usage.Weekly.ResetsAt}, - Monthly: quotaWindow{Status: decoded.Usage.Monthly.Status, Percent: decoded.Usage.Monthly.Percent, ResetsAt: decoded.Usage.Monthly.ResetsAt}, - }, nil + return pluginapi.QuotaFetchResponse{Subscription: &pluginapi.QuotaSubscription{Plan: "Go"}, Groups: []pluginapi.QuotaGroup{{DisplayName: "OpenCode Go", Buckets: buckets}}}, nil } diff --git a/internal/plugin/quota_test.go b/internal/plugin/quota_test.go index bdd9bfc..e047208 100644 --- a/internal/plugin/quota_test.go +++ b/internal/plugin/quota_test.go @@ -3,234 +3,126 @@ package plugin import ( "context" "encoding/json" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "net/http" + "opencode-go-cliproxyapi/internal/config" + "os" + "path/filepath" "strings" "testing" - - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" - - "opencode-go-cliproxyapi/internal/config" - "opencode-go-cliproxyapi/resources" ) -func TestManagementRegistration(t *testing.T) { +func TestQuotaRegistration(t *testing.T) { m := NewManager(nil) - var got struct { - Routes []struct{ Method, Path string } `json:"routes"` - Resources []struct{ Path, Menu, Description string } `json:"resources"` - } - decodeResult(t, mustHandle(t, m, pluginabi.MethodManagementRegister, []byte(`{}`)), &got) - if len(got.Routes) != 1 || got.Routes[0].Method != http.MethodPost || got.Routes[0].Path != "/plugins/"+pluginName+"/quota-usage" { - t.Fatalf("routes = %+v", got.Routes) - } - if len(got.Resources) != 1 || got.Resources[0].Path != "/quota" || got.Resources[0].Menu != "OpenCode Go Quota" { - t.Fatalf("resources = %+v", got.Resources) - } var registration registrationResult - decodeResult(t, mustHandle(t, m, pluginabi.MethodPluginRegister, lifecycleRequestBody(testValidYAML)), ®istration) - if !registration.Capabilities.ManagementAPI { - t.Fatal("registration did not advertise management_api") - } -} - -func TestQuotaListDoesNotCallHost(t *testing.T) { - f := &fakeCaller{} - m := NewManager(NewHostBridge(f.call)) - m.cfg = config.Config{APIKeys: []config.APIKey{{Value: "quota-key-a"}, {Value: "quota-key-b"}}} - var got quotaList - resp, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{Method: http.MethodPost, Path: "/v0/management/plugins/" + pluginName + "/quota-usage", Body: []byte(`{}`)}) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(resp.Body, &got); err != nil { - t.Fatal(err) - } - if len(got.Cards) != 2 || got.Cards[0].Label != "OpenCode Go credential "+mustHashPrefix("quota-key-a") || got.Cards[1].Label != "OpenCode Go credential "+mustHashPrefix("quota-key-b") { - t.Fatalf("cards = %+v", got.Cards) - } - if strings.Contains(string(resp.Body), "quota-key-") || len(f.callsOf(pluginabi.MethodHostHTTPDo)) != 0 { - t.Fatalf("list leaked key or called upstream: %s calls=%v", resp.Body, f.callsOf(pluginabi.MethodHostHTTPDo)) + decodeResult(t, registrationEnvelope(), ®istration) + if !registration.Capabilities.QuotaProvider || registration.Capabilities.ManagementAPI { + t.Fatal("incorrect quota capabilities") + } + var desc pluginapi.QuotaDescribeResponse + decodeResult(t, mustHandle(t, m, pluginabi.MethodQuotaDescribe, []byte(`{}`)), &desc) + if len(desc.SupportedProviders) != 1 || desc.SupportedProviders[0] != ProviderID || desc.SupportsReset { + t.Fatal("incorrect description") + } + for _, method := range []string{pluginabi.MethodManagementRegister, pluginabi.MethodManagementHandle, pluginabi.MethodQuotaReset} { + var env pluginabi.Envelope + json.Unmarshal(mustHandle(t, m, method, []byte(`{}`)), &env) + if env.OK { + t.Fatalf("obsolete/unsupported operation succeeded: %s", method) + } } } - -func TestQuotaRefresh(t *testing.T) { - const key = "quota-refresh-secret" +func TestQuotaSelectedCredential(t *testing.T) { f := &fakeCaller{responder: func(method string, payload []byte) ([]byte, error) { - if method != pluginabi.MethodHostHTTPDo { - return hostOK(map[string]any{}), nil - } - var wire struct { - Method string `json:"method"` - URL string `json:"url"` - Headers http.Header `json:"headers"` - } - if err := json.Unmarshal(payload, &wire); err != nil || wire.Method != http.MethodGet || wire.URL != "https://quota.test/v1/usage" || wire.Headers.Get("Authorization") != "Bearer "+key || wire.Headers.Get("Accept") != "application/json" { - t.Fatalf("bad quota request: %s", payload) + var req pluginapi.HTTPRequest + json.Unmarshal(payload, &req) + if method != pluginabi.MethodHostHTTPDo || req.Headers.Get("Authorization") != "Bearer selected" || req.URL != "https://quota.test/usage" { + t.Fatal("wrong credential or URL") } - return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"usage":{"rolling":{"status":"ok","percent":9,"resetsAt":"2026-09-05T12:20:04Z"},"weekly":{"status":"ok","percent":12,"resetsAt":"2026-09-10T00:00:00Z"},"monthly":{"status":"ok","percent":6,"resetsAt":"2026-10-01T00:00:00Z"}}}`)}), nil + return hostOK(pluginapi.HTTPResponse{StatusCode: 200, Body: []byte(`{"usage":{"rolling":{"percent":25,"resetsAt":"2026-10-01T00:00:00Z"},"weekly":{"percent":100},"monthly":{"percent":0}}}`)}), nil }} m := NewManager(NewHostBridge(f.call)) - m.cfg = config.Config{BaseURL: "https://quota.test/v1/", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: key}}} - id, _ := quotaIdentity(key) - resp, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{Method: http.MethodPost, Path: "/v0/management/plugins/" + pluginName + "/quota-usage", Body: []byte(`{"key_id":"` + id + `"}`)}) - if err != nil { - t.Fatal(err) - } - var got quotaCard - if err := json.Unmarshal(resp.Body, &got); err != nil { - t.Fatal(err) + m.cfg = config.Config{BaseURL: "https://quota.test/", APIKeys: []config.APIKey{{Value: "not-selected"}}} + body, _ := json.Marshal(pluginapi.QuotaFetchRequest{Provider: ProviderID, Attributes: map[string]string{"api_key": "selected"}}) + var got pluginapi.QuotaFetchResponse + decodeResult(t, mustHandle(t, m, pluginabi.MethodQuotaFetch, body), &got) + if len(got.Groups) != 1 || len(got.Groups[0].Buckets) != 3 { + t.Fatal("missing buckets") + } + for i, want := range []float64{0.75, 0, 1} { + if got.Groups[0].Buckets[i].RemainingFraction != want { + t.Fatal("wrong fraction") + } } - if got.Usage == nil || got.Usage.Rolling.Percent != 9 || got.Usage.Weekly.ResetsAt != "2026-09-10T00:00:00Z" || got.Usage.Monthly.Percent != 6 { - t.Fatalf("usage = %+v", got.Usage) + for _, req := range []pluginapi.QuotaFetchRequest{{Provider: "other", Attributes: map[string]string{"api_key": "selected"}}, {Provider: ProviderID}} { + if _, err := m.FetchQuota(context.Background(), req); err == nil { + t.Fatal("invalid credential accepted") + } } } - -func TestQuotaUnknownKeyAndResource(t *testing.T) { - const key = "quota-known-secret" - f := &fakeCaller{} - m := NewManager(NewHostBridge(f.call)) - m.cfg = config.Config{APIKeys: []config.APIKey{{Value: key}}} - unknown := "opencode-go-key-unknown" - resp, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{Method: http.MethodPost, Path: "/v0/management/plugins/" + pluginName + "/quota-usage", Body: []byte(`{"key_id":"` + unknown + `"}`)}) - if err != nil || resp.StatusCode != http.StatusNotFound || strings.Contains(string(resp.Body), key) || strings.Contains(string(resp.Body), unknown) || len(f.callsOf(pluginabi.MethodHostHTTPDo)) != 0 { - t.Fatalf("unknown response = %+v err=%v calls=%v", resp, err, f.callsOf(pluginabi.MethodHostHTTPDo)) +func TestQuotaValidation(t *testing.T) { + for _, body := range []string{`{}`, `{"usage":{"rolling":{}}}`, `{"usage":{"rolling":{"percent":null}}}`, `{"usage":{"rolling":{"percent":-1}}}`, `{"usage":{"rolling":{"percent":101}}}`, `{"usage":{"rolling":{"percent":true}}}`, `{"usage":{"rolling":{"percent":1,"resetsAt":"invalid"}}}`} { + if _, err := normalizeQuota([]byte(body)); err == nil { + t.Fatalf("invalid response accepted: %s", body) + } } - resource, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{Method: http.MethodGet, Path: "/v0/resource/plugins/" + pluginName + "/quota"}) - if err != nil || resource.StatusCode != 0 || resource.Headers.Get("Content-Type") != "text/html; charset=utf-8" || !strings.Contains(string(resource.Body), "OpenCode Go Quota") || strings.Contains(string(resource.Body), key) { - t.Fatalf("resource response = %+v err=%v", resource, err) + got, err := normalizeQuota([]byte(`{"usage":{"weekly":{"percent":12.5}}}`)) + if err != nil || len(got.Groups[0].Buckets) != 1 || got.Groups[0].Buckets[0].RemainingFraction != 0.875 { + t.Fatal("partial/fractional response failed") } } - -func TestQuotaPageReadsRememberedManagementKey(t *testing.T) { - if !strings.Contains(resources.QuotaPage, "parsed.state && parsed.state.managementKey") { - t.Fatal("quota page does not read persisted state.managementKey") - } -} - -func TestQuotaPageDecodesCurrentCPAAuthStorage(t *testing.T) { - for _, marker := range []string{"enc::v1::", "cli-proxy-api-webui::secure-storage", "TextDecoder"} { - if !strings.Contains(resources.QuotaPage, marker) { - t.Fatalf("quota page does not decode current CPA auth storage: missing %q", marker) +func TestQuotaErrorsRedacted(t *testing.T) { + for _, response := range []pluginapi.HTTPResponse{{StatusCode: 401, Body: []byte("secret")}, {StatusCode: http.StatusOK, Body: []byte("secret")}} { + m := NewManager(NewHostBridge((&fakeCaller{responder: func(string, []byte) ([]byte, error) { return hostOK(response), nil }}).call)) + m.cfg = config.Config{BaseURL: "https://quota.test"} + _, err := m.FetchQuota(context.Background(), pluginapi.QuotaFetchRequest{Provider: ProviderID, Attributes: map[string]string{"api_key": "secret"}}) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("unredacted or missing error") } } } - -func TestQuotaErrorsAreRedacted(t *testing.T) { - const key = "quota-error-secret" - id, _ := quotaIdentity(key) - for name, responder := range map[string]func(string, []byte) ([]byte, error){ - "bridge": func(string, []byte) ([]byte, error) { return nil, context.Canceled }, - "status": func(string, []byte) ([]byte, error) { - return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusUnauthorized, Body: []byte(key + " upstream body")}), nil - }, - "json": func(string, []byte) ([]byte, error) { - return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusOK, Body: []byte(key + " not json")}), nil - }, - } { - t.Run(name, func(t *testing.T) { - f := &fakeCaller{responder: responder} - m := NewManager(NewHostBridge(f.call)) - m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: key}}} - resp, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{Method: http.MethodPost, Path: "/v0/management/plugins/" + pluginName + "/quota-usage", Body: []byte(`{"key_id":"` + id + `"}`)}) - if err != nil || resp.StatusCode != http.StatusBadGateway || strings.Contains(string(resp.Body), key) || strings.Contains(string(resp.Body), "upstream body") { - t.Fatalf("response = %+v err=%v", resp, err) - } - }) - } -} - -func TestQuotaPageIsStaticAndSecretFree(t *testing.T) { - if resources.QuotaPage == "" || strings.Contains(resources.QuotaPage, testKey) || strings.Contains(resources.QuotaPage, "setInterval") || strings.Contains(resources.QuotaPage, "reset button") { - t.Fatal("quota page contains a secret, polling, or reset action") - } - if !strings.Contains(resources.QuotaPage, "textContent") || !strings.Contains(resources.QuotaPage, "Remember password") { - t.Fatal("quota page missing safe rendering or login guidance") +func TestAccountLabels(t *testing.T) { + cfg, err := config.Load([]byte("api-keys:\n - value: one\n name: Personal\n - value: two\n")) + if err != nil { + t.Fatal(err) } -} - -func TestQuotaPageUsesManualSessionCache(t *testing.T) { - page := resources.QuotaPage - for _, marker := range []string{ - `const storageKey = "opencode-go-cliproxyapi:quota"`, - "sessionStorage.getItem(storageKey)", - "sessionStorage.setItem(storageKey, JSON.stringify(cache))", - "JSON.parse(stored)", - "typeof parsed === \"object\"", - "Array.isArray(parsed)", - "catch (_) {}", - "Object.entries(cache)", - "values.set(keyID, entry.usage)", - "cache[card.key_id] = {usage: result.usage, fetched_at: timestamp}", - "delete cache[keyID]", - "new Set(cards.map(card => card.key_id))", - "toLocaleString", - } { - if !strings.Contains(page, marker) { - t.Fatalf("quota page missing session cache marker %q", marker) + for _, tt := range []struct{ key, old, want string }{{"one", "OpenCode Go credential hash", "Personal"}, {"two", "opencode-go-key-hash", "OpenCode Go 2"}, {"two", "Work", "Work"}} { + if got := accountLabel(cfg, tt.key, tt.old, 0); got != tt.want { + t.Fatalf("got %q want %q", got, tt.want) } } - for _, marker := range []string{ - "visibilitychange", - "document.hidden", - "window.onfocus", - "window.addEventListener(\"focus\"", - "TTL", - "expiry", - "expiration", - "bulk refresh", - } { - if strings.Contains(page, marker) { - t.Fatalf("quota page contains prohibited automatic or expiry behavior %q", marker) - } + raw := []byte(`{"type":"opencode-go","id":"stable-id","label":"OpenCode Go credential hash","api_key":"one","disabled":true}`) + result, err := (authProvider{cfg: cfg}).ParseAuth(context.Background(), pluginapi.AuthParseRequest{Provider: ProviderID, RawJSON: raw}) + if err != nil || result.Auth.Label != "Personal" || result.Auth.ID != "stable-id" || string(result.Auth.StorageJSON) != string(raw) { + t.Fatal("name migration changed credential storage") + } + if accountLabel(config.Config{}, "key", "", 0) != "OpenCode Go" { + t.Fatal("bad default name") } } -func TestQuotaPageUsesNativeQuotaStylesAndThemeBridge(t *testing.T) { - for _, marker := range []string{ - "quota-page", - "quota-grid", - "quota-card", - "quota-track", - "quota-fill", - "repeat(auto-fill, minmax(380px, 1fr))", - "@media (max-width: 768px)", - `[data-theme="white"]`, - `[data-theme="dark"]`, - `window.parent.document`, - `data-theme`, - `--bg-secondary`, - `frameElement.style.backgroundColor`, - `frameElement.parentElement.style.backgroundColor`, - ".quota-refresh", - "--bg-secondary: #faf9f5", - "--bg-primary: #f0eee8", - "--bg-tertiary: #e9e6df", - "--text-primary: #2d2a26", - "--text-secondary: #6d6760", - "--text-tertiary: #a29c95", - "--border-color: #e3e1db", - "--primary-color: #8b8680", - "--primary-hover: #7f7a74", - "border-radius: 8px; padding: 8px 10px", - `MutationObserver`, - `catch (_) {}`, - } { - if !strings.Contains(resources.QuotaPage, marker) { - t.Fatalf("quota page missing styling marker %q", marker) +func TestReadableAuthFilenameAtColdStartDoesNotDuplicate(t *testing.T) { + path := filepath.Join(t.TempDir(), "Personal.json") + raw := []byte(`{"type":"opencode-go","api_key":"existing","label":"Personal","disabled":true}`) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + f := &fakeCaller{responder: func(method string, _ []byte) ([]byte, error) { + if method == pluginabi.MethodHostAuthList { + return hostOK(hostAuthListResponse{Files: []pluginapi.HostAuthFileEntry{{Name: "Personal.json", Provider: ProviderID, Path: path}}}), nil } + return hostOK(map[string]any{}), nil + }} + m := NewManager(NewHostBridge(f.call)) + if err := m.materializeAuthRecords(context.Background(), config.Config{APIKeys: []config.APIKey{{Value: "existing"}}}); err != nil { + t.Fatal(err) } - if strings.Contains(resources.QuotaPage, `textContent = "Refresh card"`) || strings.Contains(resources.QuotaPage, "quota-button") || strings.Contains(resources.QuotaPage, "quota-refresh-small") { - t.Fatal("quota page does not have exactly one secondary refresh button path") + if len(f.callsOf(pluginabi.MethodHostAuthSave)) != 0 { + t.Fatal("duplicated existing credential") } - for _, marker := range []string{"querySelectorAll('head link[rel=\"stylesheet\"], head style')", "cloneNode(true)", "dataset.cpaStyle"} { - if strings.Contains(resources.QuotaPage, marker) { - t.Fatalf("quota page still clones parent styles: %q", marker) - } + after, _ := os.ReadFile(path) + if string(after) != string(raw) { + t.Fatal("modified host metadata") } } - -func mustHashPrefix(key string) string { - _, label := quotaIdentity(key) - return strings.TrimPrefix(label, "OpenCode Go credential ") -} diff --git a/internal/plugin/refresh_test.go b/internal/plugin/refresh_test.go index c451a3b..b4d17cd 100644 --- a/internal/plugin/refresh_test.go +++ b/internal/plugin/refresh_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/catalog" "opencode-go-cliproxyapi/internal/config" diff --git a/internal/plugin/session_test.go b/internal/plugin/session_test.go index 52304b7..a9eced4 100644 --- a/internal/plugin/session_test.go +++ b/internal/plugin/session_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) func TestResolveOpenCodeSessionIDPrecedence(t *testing.T) { diff --git a/internal/thinking/thinking.go b/internal/thinking/thinking.go index 5885646..251f3ef 100644 --- a/internal/thinking/thinking.go +++ b/internal/thinking/thinking.go @@ -1,6 +1,6 @@ // Package thinking maps reasoning budgets to canonical effort levels that a // target model actually supports, so reasoning controls are never silently -// lost (FR-005). Conversion core follows the pinned SDK v7.2.138 host tables +// lost (FR-005). Conversion core follows the pinned SDK v8.0.0 host tables // (internal/thinking/convert.go): budget↔level is a fixed threshold mapping, // never proportional interpolation; the capability-aware layer then clamps // the derived standard level to the model's supported set. @@ -11,7 +11,7 @@ import ( "slices" "strings" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/errclass" ) @@ -21,7 +21,7 @@ import ( var CanonicalLevels = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} // levelBudgetTable ports the host's levelToBudgetMap verbatim -// (SDK internal/thinking/convert.go @ v7.2.138): exact per-level budgets, +// (SDK internal/thinking/convert.go @ v8.0.0): exact per-level budgets, // no interpolation. var levelBudgetTable = map[string]int64{ "minimal": 512, @@ -33,7 +33,7 @@ var levelBudgetTable = map[string]int64{ } // budgetToLevel ports the host's ConvertBudgetToLevel fixed thresholds -// (SDK internal/thinking/convert.go @ v7.2.138: ThresholdMinimal/Low/Medium/ +// (SDK internal/thinking/convert.go @ v8.0.0: ThresholdMinimal/Low/Medium/ // High = 512/1024/8192/24576): <=512 minimal, <=1024 low, <=8192 medium, // <=24576 high, else xhigh. Callers handle the -1→auto and 0→none sentinels; // "max" is never derived from a budget. @@ -111,7 +111,7 @@ func EffortFromBudget(budget int64, ts *pluginapi.ThinkingSupport) string { // BudgetFromEffort maps a client-declared effort level to an Anthropic-style // budget_tokens value for models whose upstream protocol takes budgets // (FR-005), using the host levelToBudgetMap exact values (SDK -// internal/thinking/convert.go @ v7.2.138) instead of interpolation. ok is +// internal/thinking/convert.go @ v8.0.0) instead of interpolation. ok is // false when the effort cannot be represented for ts so callers raise an // explicit unsupported-class error instead of silently dropping the control. // diff --git a/internal/thinking/thinking_test.go b/internal/thinking/thinking_test.go index 7c5fe8a..a3e0bd5 100644 --- a/internal/thinking/thinking_test.go +++ b/internal/thinking/thinking_test.go @@ -3,7 +3,7 @@ package thinking import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/errclass" ) @@ -80,7 +80,7 @@ func TestEffortFromBudgetDisabledBudget(t *testing.T) { } // TestEffortFromBudgetThresholdTable pins the ported host threshold mapping -// (SDK convert.go @ v7.2.138: <=512 minimal, <=1024 low, <=8192 medium, +// (SDK convert.go @ v8.0.0: <=512 minimal, <=1024 low, <=8192 medium, // <=24576 high, else xhigh) followed by nearest-supported clamping. func TestEffortFromBudgetThresholdTable(t *testing.T) { cases := []struct { diff --git a/main.go b/main.go index 7bc3a04..51f264e 100644 --- a/main.go +++ b/main.go @@ -62,7 +62,7 @@ import ( "fmt" "unsafe" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" "opencode-go-cliproxyapi/internal/plugin" ) diff --git a/resources/embed.go b/resources/embed.go deleted file mode 100644 index d3cc7cd..0000000 --- a/resources/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package resources - -import _ "embed" - -//go:embed quota_page.html -var QuotaPage string diff --git a/resources/quota_page.html b/resources/quota_page.html deleted file mode 100644 index 0135974..0000000 --- a/resources/quota_page.html +++ /dev/null @@ -1,259 +0,0 @@ - - - - - -OpenCode Go Quota - - - -
-
-
-

OpenCode Go Quota

-

Usage for each configured credential.

-
-
- - - Star on GitHub - -
-
-

Loading credentials...

-
- - - - From 0900bbeccecb8b64df55ee47c576622d72313427 Mon Sep 17 00:00:00 2001 From: zenyr Date: Sun, 4 Oct 2026 22:04:37 +0900 Subject: [PATCH 2/8] feat(quota): register as a native quota provider on v7 and v8 hosts Advertise the quota_provider capability and serve quota.identifier, quota.describe, and quota.fetch so CLIProxyAPI v7.2.159+ (including v8) reports OpenCode Go credentials as quota-capable and serves their rolling, weekly, and monthly windows through /v0/management/quota/fetch. The quota RPC method names and wire shapes are declared locally, so the SDK stays on v7.2.138 and the plugin keeps reporting schema version 3. Older hosts ignore the unknown capability and keep loading the plugin. The separate quota page stays for hosts and Management Center builds without generic plugin quota rendering. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 1 + internal/plugin/native_quota.go | 113 +++++++++++++++++++++++++ internal/plugin/native_quota_test.go | 118 +++++++++++++++++++++++++++ internal/plugin/plugin.go | 4 + 4 files changed, 236 insertions(+) create mode 100644 internal/plugin/native_quota.go create mode 100644 internal/plugin/native_quota_test.go diff --git a/README.md b/README.md index b62a393..a4fb787 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ This plugin exposes OpenCode Go as a single provider (`opencode-go`) backed by a - **Dynamic Catalog Discovery**: Fetches remote model catalogs with local fallback and custom route overrides. - **Multi-Key Auth Scheduling**: Pools multiple API keys with CLIProxyAPI's native scheduler for rotation, retries, and error cooldowns across all protocols. - **OpenCode Go Quota Page**: Management Center includes a separate `OpenCode Go Quota` page. Page load lists credentials without contacting OpenCode; each card is refreshed manually and independently, and quota values do not affect routing or CPA's native quota page. +- **Native Quota Provider**: On CLIProxyAPI `v7.2.159+` (including v8), the plugin also registers as a quota provider, so each OpenCode Go credential reports `supports_quota` in `/v0/management/auth-files` and its rolling, weekly, and monthly windows are served by `POST /v0/management/quota/fetch`. Quota reset is unsupported. Older hosts ignore the capability and keep using the separate page. ## Requirements diff --git a/internal/plugin/native_quota.go b/internal/plugin/native_quota.go new file mode 100644 index 0000000..c9eb20e --- /dev/null +++ b/internal/plugin/native_quota.go @@ -0,0 +1,113 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// Native quota RPC (CLIProxyAPI v7.2.159+), declared locally so the plugin +// keeps its v7.2.138 schema version and still loads on older hosts, which +// ignore the quota_provider capability. +const ( + methodQuotaIdentifier = "quota.identifier" + methodQuotaDescribe = "quota.describe" + methodQuotaFetch = "quota.fetch" + methodQuotaReset = "quota.reset" +) + +const quotaDisplayName = "OpenCode Go" + +type nativeQuotaFetchRequest struct { + Provider string `json:"provider"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +type nativeQuotaDescribeResponse struct { + SupportedProviders []string `json:"supported_providers,omitempty"` + DisplayName string `json:"display_name,omitempty"` + SupportsReset bool `json:"supports_reset,omitempty"` +} + +type nativeQuotaSubscription struct { + Plan string `json:"plan,omitempty"` +} + +type nativeQuotaBucket struct { + Window string `json:"window,omitempty"` + RemainingFraction float64 `json:"remainingFraction"` + ResetTime string `json:"resetTime,omitempty"` +} + +type nativeQuotaGroup struct { + DisplayName string `json:"displayName,omitempty"` + Buckets []nativeQuotaBucket `json:"buckets,omitempty"` +} + +type nativeQuotaFetchResponse struct { + Subscription *nativeQuotaSubscription `json:"subscription,omitempty"` + Groups []nativeQuotaGroup `json:"groups,omitempty"` +} + +func (m *Manager) handleNativeQuota(method string, request []byte) []byte { + switch method { + case methodQuotaIdentifier: + return okEnvelope(map[string]string{"identifier": ProviderID}) + case methodQuotaDescribe: + return okEnvelope(nativeQuotaDescribeResponse{SupportedProviders: []string{ProviderID}, DisplayName: quotaDisplayName}) + case methodQuotaFetch: + var req nativeQuotaFetchRequest + if json.Unmarshal(request, &req) != nil { + return ErrEnvelope("invalid_request", "malformed quota fetch request body") + } + resp, err := m.fetchNativeQuota(context.Background(), req) + if err != nil { + return ErrEnvelope("quota_failure", err.Error()) + } + return okEnvelope(resp) + default: + return ErrEnvelope("unsupported", "OpenCode Go does not support quota resets") + } +} + +// fetchNativeQuota uses the credential the host selected, never a config key. +func (m *Manager) fetchNativeQuota(ctx context.Context, req nativeQuotaFetchRequest) (nativeQuotaFetchResponse, error) { + if req.Provider != ProviderID { + return nativeQuotaFetchResponse{}, fmt.Errorf("unsupported quota provider") + } + key := strings.TrimSpace(req.Attributes["api_key"]) + if key == "" { + return nativeQuotaFetchResponse{}, fmt.Errorf("quota credential has no api key") + } + m.mu.RLock() + baseURL, timeout := m.cfg.BaseURL, m.cfg.RequestTimeout + m.mu.RUnlock() + usage, err := fetchQuota(ctx, m.bridge, baseURL, timeout, key) + if err != nil { + return nativeQuotaFetchResponse{}, err + } + return nativeQuotaFromUsage(usage) +} + +func nativeQuotaFromUsage(usage quotaUsage) (nativeQuotaFetchResponse, error) { + windows := []struct { + name string + window quotaWindow + }{{"rolling", usage.Rolling}, {"weekly", usage.Weekly}, {"monthly", usage.Monthly}} + buckets := make([]nativeQuotaBucket, 0, len(windows)) + for _, w := range windows { + if w.window.Status == "" && w.window.ResetsAt == "" { + continue + } + percent := min(max(w.window.Percent, 0), 100) + buckets = append(buckets, nativeQuotaBucket{Window: w.name, RemainingFraction: float64(100-percent) / 100, ResetTime: w.window.ResetsAt}) + } + if len(buckets) == 0 { + return nativeQuotaFetchResponse{}, fmt.Errorf("quota response has no readings") + } + return nativeQuotaFetchResponse{ + Subscription: &nativeQuotaSubscription{Plan: "Go"}, + Groups: []nativeQuotaGroup{{DisplayName: quotaDisplayName, Buckets: buckets}}, + }, nil +} diff --git a/internal/plugin/native_quota_test.go b/internal/plugin/native_quota_test.go new file mode 100644 index 0000000..52c59c0 --- /dev/null +++ b/internal/plugin/native_quota_test.go @@ -0,0 +1,118 @@ +package plugin + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + + "opencode-go-cliproxyapi/internal/config" +) + +func TestNativeQuotaRegistrationKeepsLegacySchema(t *testing.T) { + var registration struct { + SchemaVersion uint32 `json:"schema_version"` + Capabilities map[string]any `json:"capabilities"` + } + decodeResult(t, mustHandle(t, NewManager(nil), pluginabi.MethodPluginRegister, lifecycleRequestBody(testValidYAML)), ®istration) + if registration.SchemaVersion != pluginabi.SchemaVersion { + t.Fatalf("schema_version = %d, want %d", registration.SchemaVersion, pluginabi.SchemaVersion) + } + if registration.Capabilities["quota_provider"] != true || registration.Capabilities["management_api"] != true { + t.Fatalf("capabilities = %+v", registration.Capabilities) + } +} + +func TestNativeQuotaIdentifierAndDescribe(t *testing.T) { + m := NewManager(nil) + var id struct{ Identifier string } + decodeResult(t, mustHandle(t, m, methodQuotaIdentifier, []byte(`{}`)), &id) + if id.Identifier != ProviderID { + t.Fatalf("identifier = %q", id.Identifier) + } + var desc nativeQuotaDescribeResponse + decodeResult(t, mustHandle(t, m, methodQuotaDescribe, []byte(`{}`)), &desc) + if len(desc.SupportedProviders) != 1 || desc.SupportedProviders[0] != ProviderID || desc.SupportsReset { + t.Fatalf("describe = %+v", desc) + } +} + +func TestNativeQuotaFetchUsesSelectedCredential(t *testing.T) { + const selected = "native-selected-secret" + f := &fakeCaller{responder: func(method string, payload []byte) ([]byte, error) { + if method != pluginabi.MethodHostHTTPDo { + return hostOK(map[string]any{}), nil + } + var wire struct { + URL string `json:"url"` + Headers http.Header `json:"headers"` + } + if err := json.Unmarshal(payload, &wire); err != nil || wire.URL != "https://quota.test/v1/usage" || wire.Headers.Get("Authorization") != "Bearer "+selected { + t.Fatalf("bad quota request: %s", payload) + } + return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"usage":{"rolling":{"status":"ok","percent":0,"resetsAt":"2026-10-04T15:26:49.000Z"},"weekly":{"status":"ok","percent":4,"resetsAt":"2026-10-05T00:00:00.000Z"},"monthly":{"status":"ok","percent":41,"resetsAt":"2026-10-26T10:20:31.000Z"}}}`)}), nil + }} + m := NewManager(NewHostBridge(f.call)) + m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: "config-default-key"}}} + var got nativeQuotaFetchResponse + decodeResult(t, mustHandle(t, m, methodQuotaFetch, []byte(`{"auth_index":"1","provider":"opencode-go","attributes":{"api_key":"`+selected+`"}}`)), &got) + if got.Subscription == nil || got.Subscription.Plan != "Go" || len(got.Groups) != 1 { + t.Fatalf("response = %+v", got) + } + want := []nativeQuotaBucket{ + {Window: "rolling", RemainingFraction: 1, ResetTime: "2026-10-04T15:26:49.000Z"}, + {Window: "weekly", RemainingFraction: 0.96, ResetTime: "2026-10-05T00:00:00.000Z"}, + {Window: "monthly", RemainingFraction: 0.59, ResetTime: "2026-10-26T10:20:31.000Z"}, + } + buckets := got.Groups[0].Buckets + if len(buckets) != len(want) { + t.Fatalf("buckets = %+v", buckets) + } + for i := range want { + if buckets[i] != want[i] { + t.Fatalf("bucket %d = %+v, want %+v", i, buckets[i], want[i]) + } + } +} + +func TestNativeQuotaSkipsMissingWindowsAndClampsPercent(t *testing.T) { + got, err := nativeQuotaFromUsage(quotaUsage{Weekly: quotaWindow{Status: "ok", Percent: 130}}) + if err != nil || len(got.Groups[0].Buckets) != 1 || got.Groups[0].Buckets[0] != (nativeQuotaBucket{Window: "weekly", RemainingFraction: 0}) { + t.Fatalf("response = %+v err=%v", got, err) + } + if _, err := nativeQuotaFromUsage(quotaUsage{}); err == nil { + t.Fatal("empty usage did not fail") + } +} + +func TestNativeQuotaFailuresAreRedacted(t *testing.T) { + const key = "native-error-secret" + f := &fakeCaller{responder: func(string, []byte) ([]byte, error) { + return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusUnauthorized, Body: []byte(key + " upstream body")}), nil + }} + m := NewManager(NewHostBridge(f.call)) + m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout} + for name, body := range map[string]string{ + "upstream": `{"provider":"opencode-go","attributes":{"api_key":"` + key + `"}}`, + "provider": `{"provider":"codex","attributes":{"api_key":"` + key + `"}}`, + "no key": `{"provider":"opencode-go"}`, + "json": `{`, + } { + t.Run(name, func(t *testing.T) { + resp := string(mustHandle(t, m, methodQuotaFetch, []byte(body))) + if !strings.Contains(resp, `"ok":false`) || strings.Contains(resp, key) || strings.Contains(resp, "upstream body") { + t.Fatalf("response = %s", resp) + } + }) + } +} + +func TestNativeQuotaResetIsUnsupported(t *testing.T) { + resp := string(mustHandle(t, NewManager(nil), methodQuotaReset, []byte(`{}`))) + if !strings.Contains(resp, `"ok":false`) || !strings.Contains(resp, "unsupported") { + t.Fatalf("response = %s", resp) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 057605c..43598cc 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -128,6 +128,8 @@ func (m *Manager) HandleCall(method string, request []byte) (resp []byte, err er return m.registerManagement(request) case pluginabi.MethodManagementHandle: return m.handleManagement(request) + case methodQuotaIdentifier, methodQuotaDescribe, methodQuotaFetch, methodQuotaReset: + return m.handleNativeQuota(method, request), nil case pluginabi.MethodExecutorIdentifier: return okEnvelope(map[string]string{"identifier": ProviderID}), nil case pluginabi.MethodExecutorCountTokens: @@ -160,6 +162,7 @@ type capabilities struct { ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"` ManagementAPI bool `json:"management_api"` + QuotaProvider bool `json:"quota_provider"` } type registrationResult struct { @@ -242,6 +245,7 @@ func registrationEnvelope() []byte { ExecutorInputFormats: formats, ExecutorOutputFormats: formats, ManagementAPI: true, + QuotaProvider: true, }, }) } From 8add15638745bca0de7b02ec0b74f9cc9b44e0b1 Mon Sep 17 00:00:00 2001 From: zenyr Date: Sun, 4 Oct 2026 22:12:39 +0900 Subject: [PATCH 3/8] fix(quota): bound quota lookups and pin the legacy schema in tests Cap quota upstream lookups at 30 seconds for both the native provider and the separate page, since the default request timeout is minutes long. Assert schema version 3 literally and check the fetch wire keys, so an SDK bump that breaks older hosts fails the tests. Add the native quota provider to the release notes. Co-Authored-By: Claude Opus 5.5 (1M context) --- RELEASE_NOTES.md | 4 ++++ internal/plugin/native_quota_test.go | 29 +++++++++++++++++++++++++--- internal/plugin/quota.go | 12 +++++++++++- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fd0d512..24bb764 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,10 @@ Full OpenAI Codex CLI support across all models: multi-agent namespaces, custom programmatic tools (`exec`), tool call results, and reasoning efforts are fully functional. +### Features + +- Register as a native CLIProxyAPI quota provider: on hosts `v7.2.159+` (including v8), OpenCode Go credentials report `supports_quota` in `/v0/management/auth-files` and serve rolling, weekly, and monthly windows through `POST /v0/management/quota/fetch`. The plugin keeps schema version 3, so older v7 hosts still load it and keep using the separate `OpenCode Go Quota` page. Quota lookups are now capped at 30 seconds. + ### Bug Fixes - Fix missing Responses stream item lifecycle completion events (output_text.done, content_part.done, function_call_arguments.done, output_item.done) prior to response.completed, resolving dropped assistant output and tool calls in OpenAI Codex CLI and strict Responses clients. diff --git a/internal/plugin/native_quota_test.go b/internal/plugin/native_quota_test.go index 52c59c0..33bdea9 100644 --- a/internal/plugin/native_quota_test.go +++ b/internal/plugin/native_quota_test.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" @@ -18,8 +19,9 @@ func TestNativeQuotaRegistrationKeepsLegacySchema(t *testing.T) { Capabilities map[string]any `json:"capabilities"` } decodeResult(t, mustHandle(t, NewManager(nil), pluginabi.MethodPluginRegister, lifecycleRequestBody(testValidYAML)), ®istration) - if registration.SchemaVersion != pluginabi.SchemaVersion { - t.Fatalf("schema_version = %d, want %d", registration.SchemaVersion, pluginabi.SchemaVersion) + // Hosts before v7.2.159 reject plugins whose schema is newer than 3. + if registration.SchemaVersion != 3 { + t.Fatalf("schema_version = %d, want 3", registration.SchemaVersion) } if registration.Capabilities["quota_provider"] != true || registration.Capabilities["management_api"] != true { t.Fatalf("capabilities = %+v", registration.Capabilities) @@ -57,8 +59,15 @@ func TestNativeQuotaFetchUsesSelectedCredential(t *testing.T) { }} m := NewManager(NewHostBridge(f.call)) m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: "config-default-key"}}} + raw := mustHandle(t, m, methodQuotaFetch, []byte(`{"auth_index":"1","provider":"opencode-go","attributes":{"api_key":"`+selected+`"},"host_callback_id":"cb-1"}`)) + var wire map[string]any + decodeResult(t, raw, &wire) + bucket := wire["groups"].([]any)[0].(map[string]any)["buckets"].([]any)[1].(map[string]any) + if wire["subscription"].(map[string]any)["plan"] != "Go" || bucket["window"] != "weekly" || bucket["remainingFraction"] != 0.96 || bucket["resetTime"] != "2026-10-05T00:00:00.000Z" { + t.Fatalf("wire response = %+v", wire) + } var got nativeQuotaFetchResponse - decodeResult(t, mustHandle(t, m, methodQuotaFetch, []byte(`{"auth_index":"1","provider":"opencode-go","attributes":{"api_key":"`+selected+`"}}`)), &got) + decodeResult(t, raw, &got) if got.Subscription == nil || got.Subscription.Plan != "Go" || len(got.Groups) != 1 { t.Fatalf("response = %+v", got) } @@ -110,6 +119,20 @@ func TestNativeQuotaFailuresAreRedacted(t *testing.T) { } } +func TestQuotaTimeoutIsBounded(t *testing.T) { + for in, want := range map[time.Duration]time.Duration{ + 0: maxQuotaTimeout, + config.DefaultRequestTimeout: maxQuotaTimeout, + 5 * time.Second: 5 * time.Second, + maxQuotaTimeout: maxQuotaTimeout, + -time.Second: maxQuotaTimeout, + } { + if got := quotaTimeout(in); got != want { + t.Fatalf("quotaTimeout(%s) = %s, want %s", in, got, want) + } + } +} + func TestNativeQuotaResetIsUnsupported(t *testing.T) { resp := string(mustHandle(t, NewManager(nil), methodQuotaReset, []byte(`{}`))) if !strings.Contains(resp, `"ok":false`) || !strings.Contains(resp, "unsupported") { diff --git a/internal/plugin/quota.go b/internal/plugin/quota.go index cf069f4..e92a66f 100644 --- a/internal/plugin/quota.go +++ b/internal/plugin/quota.go @@ -109,11 +109,21 @@ func quotaJSON(v any) (pluginapi.ManagementResponse, error) { return pluginapi.ManagementResponse{Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: body}, nil } +// maxQuotaTimeout bounds quota lookups, which serve interactive management calls. +const maxQuotaTimeout = 30 * time.Second + +func quotaTimeout(requestTimeout time.Duration) time.Duration { + if requestTimeout <= 0 || requestTimeout > maxQuotaTimeout { + return maxQuotaTimeout + } + return requestTimeout +} + func fetchQuota(ctx context.Context, bridge *HostBridge, baseURL string, timeout time.Duration, key string) (quotaUsage, error) { if bridge == nil { return quotaUsage{}, fmt.Errorf("quota bridge unavailable") } - ctx, cancel := context.WithTimeout(ctx, timeout) + ctx, cancel := context.WithTimeout(ctx, quotaTimeout(timeout)) defer cancel() resp, err := bridge.Do(ctx, pluginapi.HTTPRequest{Method: http.MethodGet, URL: strings.TrimRight(baseURL, "/") + "/usage", Headers: http.Header{"Authorization": []string{"Bearer " + key}, "Accept": []string{"application/json"}}}) if err != nil || resp.StatusCode != http.StatusOK { From 97548372f8bee987b3c631e1d7a7286bdf66663f Mon Sep 17 00:00:00 2001 From: zenyr Date: Sun, 4 Oct 2026 22:47:06 +0900 Subject: [PATCH 4/8] fix(quota): report the rolling window as 5h OpenCode Go's rolling limit spans five hours. Reporting it as 5h lets Management Center label and order it like other five-hour windows. Document the v8 Management API quota route as well. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- internal/plugin/native_quota.go | 3 ++- internal/plugin/native_quota_test.go | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a4fb787..fb5096d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ This plugin exposes OpenCode Go as a single provider (`opencode-go`) backed by a - **Dynamic Catalog Discovery**: Fetches remote model catalogs with local fallback and custom route overrides. - **Multi-Key Auth Scheduling**: Pools multiple API keys with CLIProxyAPI's native scheduler for rotation, retries, and error cooldowns across all protocols. - **OpenCode Go Quota Page**: Management Center includes a separate `OpenCode Go Quota` page. Page load lists credentials without contacting OpenCode; each card is refreshed manually and independently, and quota values do not affect routing or CPA's native quota page. -- **Native Quota Provider**: On CLIProxyAPI `v7.2.159+` (including v8), the plugin also registers as a quota provider, so each OpenCode Go credential reports `supports_quota` in `/v0/management/auth-files` and its rolling, weekly, and monthly windows are served by `POST /v0/management/quota/fetch`. Quota reset is unsupported. Older hosts ignore the capability and keep using the separate page. +- **Native Quota Provider**: On CLIProxyAPI `v7.2.159+` (including v8), the plugin also registers as a quota provider, so each OpenCode Go credential reports `supports_quota` in `/v0/management/auth-files` and its rolling (reported as the `5h` window), weekly, and monthly windows are served by `POST /v0/management/quota/fetch` (v8 Management API: `POST /v8/management/plugins/opencode-go-cliproxyapi/quota`). Quota reset is unsupported. Older hosts ignore the capability and keep using the separate page. ## Requirements diff --git a/internal/plugin/native_quota.go b/internal/plugin/native_quota.go index c9eb20e..c703742 100644 --- a/internal/plugin/native_quota.go +++ b/internal/plugin/native_quota.go @@ -91,10 +91,11 @@ func (m *Manager) fetchNativeQuota(ctx context.Context, req nativeQuotaFetchRequ } func nativeQuotaFromUsage(usage quotaUsage) (nativeQuotaFetchResponse, error) { + // OpenCode Go's rolling window spans five hours. windows := []struct { name string window quotaWindow - }{{"rolling", usage.Rolling}, {"weekly", usage.Weekly}, {"monthly", usage.Monthly}} + }{{"5h", usage.Rolling}, {"weekly", usage.Weekly}, {"monthly", usage.Monthly}} buckets := make([]nativeQuotaBucket, 0, len(windows)) for _, w := range windows { if w.window.Status == "" && w.window.ResetsAt == "" { diff --git a/internal/plugin/native_quota_test.go b/internal/plugin/native_quota_test.go index 33bdea9..9e24b41 100644 --- a/internal/plugin/native_quota_test.go +++ b/internal/plugin/native_quota_test.go @@ -72,7 +72,7 @@ func TestNativeQuotaFetchUsesSelectedCredential(t *testing.T) { t.Fatalf("response = %+v", got) } want := []nativeQuotaBucket{ - {Window: "rolling", RemainingFraction: 1, ResetTime: "2026-10-04T15:26:49.000Z"}, + {Window: "5h", RemainingFraction: 1, ResetTime: "2026-10-04T15:26:49.000Z"}, {Window: "weekly", RemainingFraction: 0.96, ResetTime: "2026-10-05T00:00:00.000Z"}, {Window: "monthly", RemainingFraction: 0.59, ResetTime: "2026-10-26T10:20:31.000Z"}, } From a7a6b85aea22e386c9036937014a65e4d2c89090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=89=E8=89=B2/Yosef/=E4=BD=95=E7=82=9C?= Date: Mon, 5 Oct 2026 08:32:06 +0800 Subject: [PATCH 5/8] fix(catalog): route space bunny models and document strict sync --- README.md | 22 ++++++++++++++++++++++ internal/catalog/catalog.go | 4 ++++ internal/catalog/catalog_test.go | 2 ++ 3 files changed, 28 insertions(+) diff --git a/README.md b/README.md index 5377c65..7359e1b 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,28 @@ plugins: | `max-response-bytes` | `int64` | `67108864` (64 MiB) | Maximum non-streaming response body size in bytes. | | `allow-http` | `bool` | `false` | When `true`, permits `http://` scheme in `base-url` / `catalog-url` for local testing. | +### Keeping the model list aligned with OpenCode Go + +The plugin replaces its `opencode-go` catalog with the latest successful +response from OpenCode Go. Models removed upstream therefore disappear from +the next successful refresh. The refresh interval is configurable, with a +minimum of one minute. + +For a strict mirror, use a short interval and fail closed during an outage: + +```yaml +catalog: + refresh-interval: "1m" + stale-while-unavailable: false +``` + +`stale-while-unavailable: true` is safer during a transient outage, but it +intentionally keeps the last successful model list until the next refresh. +Models whose upstream protocol is not known to the plugin are retained in +refresh diagnostics and excluded from `opencode-go` models. Add a +`route-overrides` entry only when the upstream protocol is confirmed; the +plugin does not guess a route for an unknown model. + ## Testing ```powershell diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 2d2a99b..7be9b73 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -121,6 +121,10 @@ var prefixRoutes = []struct { route Route }{ {"muse-spark", RouteResponses}, + // OpenCode Go's Space Bunny family is served through the OpenAI + // Chat Completions protocol. Keep this before any broader future + // prefixes so variants such as space-bunny-free resolve consistently. + {"space-bunny", RouteChatCompletions}, {"deepseek", RouteChatCompletions}, {"minimax", RouteMessages}, {"longcat", RouteChatCompletions}, diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 36cea66..a1d208e 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -202,6 +202,8 @@ func TestCompatExactIDs(t *testing.T) { "grok-4.5": RouteResponses, "gpt-5.6-luna": RouteResponses, "muse-spark-1.2-contributor": RouteResponses, + "muse-spark-1.3-contributor": RouteResponses, + "space-bunny-free": RouteChatCompletions, // Chat Completions. "glm-5.3": RouteChatCompletions, "glm-5.2": RouteChatCompletions, From f5806103bcbbdd9ac8500318a94f40037f02cdee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=89=E8=89=B2/Yosef/=E4=BD=95=E7=82=9C?= Date: Mon, 5 Oct 2026 08:38:27 +0800 Subject: [PATCH 6/8] test(catalog): cover free muse spark contributor model --- internal/catalog/catalog_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index a1d208e..954a5e2 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -199,11 +199,12 @@ func TestRoutePriorityMatrix(t *testing.T) { func TestCompatExactIDs(t *testing.T) { cases := map[string]Route{ // Responses. - "grok-4.5": RouteResponses, - "gpt-5.6-luna": RouteResponses, - "muse-spark-1.2-contributor": RouteResponses, - "muse-spark-1.3-contributor": RouteResponses, - "space-bunny-free": RouteChatCompletions, + "grok-4.5": RouteResponses, + "gpt-5.6-luna": RouteResponses, + "muse-spark-1.2-contributor": RouteResponses, + "muse-spark-1.3-contributor": RouteResponses, + "muse-spark-1.3-contributor-free": RouteResponses, + "space-bunny-free": RouteChatCompletions, // Chat Completions. "glm-5.3": RouteChatCompletions, "glm-5.2": RouteChatCompletions, From 74f9d0ab0d18c3ba7335132df2e825869e782172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=89=E8=89=B2/Yosef/=E4=BD=95=E7=82=9C?= Date: Mon, 5 Oct 2026 09:21:59 +0800 Subject: [PATCH 7/8] fix(quota): restore legacy management resource page --- README.md | 4 +- internal/plugin/management.go | 163 ++++++++++++++++++ internal/plugin/management_test.go | 77 +++++++++ internal/plugin/plugin.go | 5 + internal/plugin/quota_test.go | 4 +- resources/embed.go | 6 + resources/quota_page.html | 259 +++++++++++++++++++++++++++++ 7 files changed, 514 insertions(+), 4 deletions(-) create mode 100644 internal/plugin/management.go create mode 100644 internal/plugin/management_test.go create mode 100644 resources/embed.go create mode 100644 resources/quota_page.html diff --git a/README.md b/README.md index 7359e1b..9f35f1d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ This plugin exposes OpenCode Go as a single provider (`opencode-go`) backed by a - **Thinking & Reasoning Support**: Maps reasoning effort across supported client and upstream formats. - **Dynamic Catalog Discovery**: Fetches remote model catalogs with local fallback and custom route overrides. - **Multi-Key Auth Scheduling**: Pools multiple API keys with CLIProxyAPI's native scheduler for rotation, retries, and error cooldowns across all protocols. -- **Native quotas**: Rolling, weekly, and monthly quotas use CLIProxyAPI’s generic quota endpoints and the selected credential. The separate plugin page is removed. +- **Native quotas**: Rolling, weekly, and monthly quotas use CLIProxyAPI’s generic quota endpoints and the selected credential. A compatibility resource page is also exposed for older Management Center builds. ## Requirements @@ -181,4 +181,4 @@ Discover support with `GET /v0/management/quota/providers`, then call These endpoints require the CLIProxyAPI management key. The normalized response contains `subscription.plan`, `groups[].buckets[].window`, `remainingFraction`, and `resetTime`. Missing windows are omitted. Invalid readings return an error, not an invented zero. -Quota reset is unsupported. The old plugin quota route and resource page are removed. +Quota reset is unsupported. For older Management Center builds, the plugin also exposes a compatibility quota resource page backed by the same native quota provider. diff --git a/internal/plugin/management.go b/internal/plugin/management.go new file mode 100644 index 0000000..4116699 --- /dev/null +++ b/internal/plugin/management.go @@ -0,0 +1,163 @@ +package plugin + +// This compatibility resource keeps the quota page usable with Management +// Center builds that predate CLIProxyAPI v8's generic quota UI. The native +// quota provider remains the source of truth; this route only adapts its +// response to the legacy embedded page. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" + + "opencode-go-cliproxyapi/internal/config" + "opencode-go-cliproxyapi/resources" +) + +type legacyQuotaWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt string `json:"resets_at"` +} + +type legacyQuotaUsage struct { + Rolling legacyQuotaWindow `json:"rolling"` + Weekly legacyQuotaWindow `json:"weekly"` + Monthly legacyQuotaWindow `json:"monthly"` +} + +type legacyQuotaCard struct { + KeyID string `json:"key_id"` + Label string `json:"label"` + Usage *legacyQuotaUsage `json:"usage,omitempty"` +} + +type legacyQuotaList struct { + Cards []legacyQuotaCard `json:"cards"` +} + +type legacyQuotaRequest struct { + KeyID string `json:"key_id"` +} + +func quotaIdentity(key string) (id, label string) { + digest := sha256.Sum256([]byte(key)) + hash := hex.EncodeToString(digest[:]) + return "opencode-go-key-" + hash, "OpenCode Go credential " + hash[:12] +} + +func (m *Manager) registerManagement(request []byte) ([]byte, error) { + var req struct { + Plugin pluginapi.Metadata `json:"Plugin"` + BasePath string `json:"BasePath"` + ResourceBasePath string `json:"ResourceBasePath"` + } + if err := json.Unmarshal(request, &req); err != nil { + return ErrEnvelope("invalid_request", "malformed management registration request body"), nil + } + return okEnvelope(struct { + Routes []struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"routes"` + Resources []struct { + Path string `json:"path"` + Menu string `json:"menu"` + Description string `json:"description"` + } `json:"resources"` + }{ + Routes: []struct { + Method string `json:"method"` + Path string `json:"path"` + }{{Method: http.MethodPost, Path: "/plugins/" + pluginName + "/quota-usage"}}, + Resources: []struct { + Path string `json:"path"` + Menu string `json:"menu"` + Description string `json:"description"` + }{{Path: "/quota", Menu: "OpenCode Go Quota", Description: "View OpenCode Go quota windows."}}, + }), nil +} + +func (m *Manager) handleManagement(request []byte) ([]byte, error) { + var req struct { + pluginapi.ManagementRequest + HostCallbackID string `json:"host_callback_id,omitempty"` + } + if err := json.Unmarshal(request, &req); err != nil { + return ErrEnvelope("invalid_request", "malformed management request body"), nil + } + resp, err := m.HandleManagement(context.Background(), req.ManagementRequest) + if err != nil { + return ErrEnvelope("management_failure", err.Error()), nil + } + raw, _ := json.Marshal(resp) + return okEnvelope(json.RawMessage(raw)), nil +} + +func (m *Manager) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { + if req.Method == http.MethodGet && req.Path == "/v0/resource/plugins/"+pluginName+"/quota" { + return pluginapi.ManagementResponse{ + Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, + Body: []byte(resources.QuotaPage), + }, nil + } + if req.Method != http.MethodPost || req.Path != "/v0/management/plugins/"+pluginName+"/quota-usage" { + return pluginapi.ManagementResponse{StatusCode: http.StatusNotFound, Body: []byte(`{"error":"not found"}`)}, nil + } + var body legacyQuotaRequest + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &body); err != nil { + return pluginapi.ManagementResponse{StatusCode: http.StatusBadRequest, Body: []byte(`{"error":"invalid request"}`)}, nil + } + } + m.mu.RLock() + keys := append([]config.APIKey(nil), m.cfg.APIKeys...) + m.mu.RUnlock() + if body.KeyID == "" { + cards := make([]legacyQuotaCard, 0, len(keys)) + for _, key := range keys { + id, label := quotaIdentity(key.Value) + cards = append(cards, legacyQuotaCard{KeyID: id, Label: label}) + } + return quotaJSON(cards) + } + for _, key := range keys { + id, label := quotaIdentity(key.Value) + if id != body.KeyID { + continue + } + fresh, err := m.FetchQuota(ctx, pluginapi.QuotaFetchRequest{ + Provider: ProviderID, + Attributes: map[string]string{"api_key": key.Value}, + }) + if err != nil || len(fresh.Groups) == 0 { + return pluginapi.ManagementResponse{StatusCode: http.StatusBadGateway, Body: []byte(`{"error":"quota refresh failed"}`)}, nil + } + usage := legacyQuotaUsage{} + for _, bucket := range fresh.Groups[0].Buckets { + window := legacyQuotaWindow{Status: "ok", Percent: (1 - bucket.RemainingFraction) * 100, ResetsAt: bucket.ResetTime} + switch bucket.Window { + case "rolling": + usage.Rolling = window + case "weekly": + usage.Weekly = window + case "monthly": + usage.Monthly = window + } + } + return quotaJSON(legacyQuotaCard{KeyID: id, Label: label, Usage: &usage}) + } + return pluginapi.ManagementResponse{StatusCode: http.StatusNotFound, Body: []byte(`{"error":"unknown quota key"}`)}, nil +} + +func quotaJSON(value any) (pluginapi.ManagementResponse, error) { + body, err := json.Marshal(value) + if err != nil { + return pluginapi.ManagementResponse{}, err + } + return pluginapi.ManagementResponse{Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: body}, nil +} diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go new file mode 100644 index 0000000..c71aaa4 --- /dev/null +++ b/internal/plugin/management_test.go @@ -0,0 +1,77 @@ +package plugin + +import ( + "context" + "encoding/json" + "math" + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" + + "opencode-go-cliproxyapi/internal/config" +) + +func TestManagementRegistrationIncludesQuotaResource(t *testing.T) { + m := NewManager(nil) + var got struct { + Routes []struct{ Method, Path string } `json:"routes"` + Resources []struct{ Path, Menu, Description string } `json:"resources"` + } + decodeResult(t, mustHandle(t, m, pluginabi.MethodManagementRegister, []byte(`{}`)), &got) + if len(got.Routes) != 1 || got.Routes[0].Method != http.MethodPost || got.Routes[0].Path != "/plugins/"+pluginName+"/quota-usage" { + t.Fatalf("routes = %+v", got.Routes) + } + if len(got.Resources) != 1 || got.Resources[0].Path != "/quota" || got.Resources[0].Menu != "OpenCode Go Quota" { + t.Fatalf("resources = %+v", got.Resources) + } + var registration registrationResult + decodeResult(t, registrationEnvelope(), ®istration) + if !registration.Capabilities.ManagementAPI { + t.Fatal("registration did not advertise management_api") + } +} + +func TestLegacyQuotaResourceUsesNativeQuotaProvider(t *testing.T) { + const key = "legacy-quota-key" + f := &fakeCaller{responder: func(method string, payload []byte) ([]byte, error) { + if method != pluginabi.MethodHostHTTPDo { + return hostOK(map[string]any{}), nil + } + var req pluginapi.HTTPRequest + if err := json.Unmarshal(payload, &req); err != nil { + t.Fatal(err) + } + if req.URL != "https://quota.test/v1/usage" || req.Headers.Get("Authorization") != "Bearer "+key { + t.Fatalf("unexpected quota request: %+v", req) + } + return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"usage":{"rolling":{"percent":12,"resetsAt":"2026-10-05T06:00:00Z"},"weekly":{"percent":8},"monthly":{"percent":5}}}`)}), nil + }} + m := NewManager(NewHostBridge(f.call)) + m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: key}}} + id, _ := quotaIdentity(key) + resp, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{ + Method: http.MethodPost, + Path: "/v0/management/plugins/" + pluginName + "/quota-usage", + Body: []byte(`{"key_id":"` + id + `"}`), + }) + if err != nil || resp.StatusCode != 0 { + t.Fatalf("quota response = %+v err=%v", resp, err) + } + var card legacyQuotaCard + if err := json.Unmarshal(resp.Body, &card); err != nil { + t.Fatal(err) + } + if card.Usage == nil || math.Abs(card.Usage.Rolling.Percent-12) > 1e-9 || math.Abs(card.Usage.Weekly.Percent-8) > 1e-9 || math.Abs(card.Usage.Monthly.Percent-5) > 1e-9 { + t.Fatalf("usage = %+v", card.Usage) + } + resource, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/resource/plugins/" + pluginName + "/quota", + }) + if err != nil || resource.Headers.Get("Content-Type") != "text/html; charset=utf-8" || !strings.Contains(string(resource.Body), "OpenCode Go Quota") { + t.Fatalf("resource = %+v err=%v", resource, err) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 0cc1c36..b1d4bf4 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -130,6 +130,10 @@ func (m *Manager) HandleCall(method string, request []byte) (resp []byte, err er return ErrEnvelope("auth_failure", err.Error()), nil } return okEnvelope(resp), nil + case pluginabi.MethodManagementRegister: + return m.registerManagement(request) + case pluginabi.MethodManagementHandle: + return m.handleManagement(request) case pluginabi.MethodQuotaIdentifier: return okEnvelope(map[string]string{"identifier": ProviderID}), nil case pluginabi.MethodQuotaDescribe: @@ -261,6 +265,7 @@ func registrationEnvelope() []byte { ExecutorInputFormats: formats, ExecutorOutputFormats: formats, QuotaProvider: true, + ManagementAPI: true, }, }) } diff --git a/internal/plugin/quota_test.go b/internal/plugin/quota_test.go index e047208..7fdf3a4 100644 --- a/internal/plugin/quota_test.go +++ b/internal/plugin/quota_test.go @@ -17,7 +17,7 @@ func TestQuotaRegistration(t *testing.T) { m := NewManager(nil) var registration registrationResult decodeResult(t, registrationEnvelope(), ®istration) - if !registration.Capabilities.QuotaProvider || registration.Capabilities.ManagementAPI { + if !registration.Capabilities.QuotaProvider || !registration.Capabilities.ManagementAPI { t.Fatal("incorrect quota capabilities") } var desc pluginapi.QuotaDescribeResponse @@ -25,7 +25,7 @@ func TestQuotaRegistration(t *testing.T) { if len(desc.SupportedProviders) != 1 || desc.SupportedProviders[0] != ProviderID || desc.SupportsReset { t.Fatal("incorrect description") } - for _, method := range []string{pluginabi.MethodManagementRegister, pluginabi.MethodManagementHandle, pluginabi.MethodQuotaReset} { + for _, method := range []string{pluginabi.MethodQuotaReset} { var env pluginabi.Envelope json.Unmarshal(mustHandle(t, m, method, []byte(`{}`)), &env) if env.OK { diff --git a/resources/embed.go b/resources/embed.go new file mode 100644 index 0000000..d3cc7cd --- /dev/null +++ b/resources/embed.go @@ -0,0 +1,6 @@ +package resources + +import _ "embed" + +//go:embed quota_page.html +var QuotaPage string diff --git a/resources/quota_page.html b/resources/quota_page.html new file mode 100644 index 0000000..9b4bb6e --- /dev/null +++ b/resources/quota_page.html @@ -0,0 +1,259 @@ + + + + + +OpenCode Go Quota + + + +
+
+
+

OpenCode Go Quota

+

Usage for each configured credential.

+
+
+ + + Star on GitHub + +
+
+

Loading credentials...

+
+ + + + From ae9548491b1a37ea2fd4cdf0713cd566312c1bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=89=E8=89=B2/Yosef/=E4=BD=95=E7=82=9C?= Date: Mon, 5 Oct 2026 09:37:26 +0800 Subject: [PATCH 8/8] fix(quota): wrap legacy credential list response --- internal/plugin/management.go | 2 +- internal/plugin/management_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/plugin/management.go b/internal/plugin/management.go index 4116699..b00b4d4 100644 --- a/internal/plugin/management.go +++ b/internal/plugin/management.go @@ -123,7 +123,7 @@ func (m *Manager) HandleManagement(ctx context.Context, req pluginapi.Management id, label := quotaIdentity(key.Value) cards = append(cards, legacyQuotaCard{KeyID: id, Label: label}) } - return quotaJSON(cards) + return quotaJSON(legacyQuotaList{Cards: cards}) } for _, key := range keys { id, label := quotaIdentity(key.Value) diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index c71aaa4..f1fbb4e 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -52,6 +52,21 @@ func TestLegacyQuotaResourceUsesNativeQuotaProvider(t *testing.T) { m := NewManager(NewHostBridge(f.call)) m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: key}}} id, _ := quotaIdentity(key) + list, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{ + Method: http.MethodPost, + Path: "/v0/management/plugins/" + pluginName + "/quota-usage", + Body: []byte(`{}`), + }) + if err != nil || list.StatusCode != 0 { + t.Fatalf("quota list response = %+v err=%v", list, err) + } + var listed legacyQuotaList + if err := json.Unmarshal(list.Body, &listed); err != nil { + t.Fatal(err) + } + if len(listed.Cards) != 1 || listed.Cards[0].KeyID != id { + t.Fatalf("listed cards = %+v", listed.Cards) + } resp, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{ Method: http.MethodPost, Path: "/v0/management/plugins/" + pluginName + "/quota-usage",