From e90890519f5dedb912cd52a14d143d46317978ff Mon Sep 17 00:00:00 2001 From: Elliott Linder Date: Thu, 8 Oct 2026 11:20:01 +0200 Subject: [PATCH] Use CLIProxyAPI v8 native quotas and readable OpenCode Go accounts Migrate the SDK imports to v8 and replace the separate quota page with the generic quota provider API. Preserve credential identity and custom labels, support optional account names, and prevent duplicate records at cold start. Keep model reasoning metadata and protocol compatibility changes outside this commit. Verify local builds and tests with the debug tag. --- README.md | 43 ++- RELEASE_NOTES.md | 32 +- go.mod | 2 +- go.sum | 4 +- internal/adapter/chatcompletions/request.go | 2 +- .../adapter/chatcompletions/request_test.go | 4 +- internal/adapter/messages/request.go | 2 +- internal/adapter/messages/request_test.go | 3 +- 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, 361 insertions(+), 689 deletions(-) delete mode 100644 resources/embed.go delete mode 100644 resources/quota_page.html diff --git a/README.md b/README.md index b62a393..8437715 100644 --- a/README.md +++ b/README.md @@ -31,30 +31,31 @@ 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 -Build the dynamic shared library for your platform: +Build the shared library with the `debug` tag for local development. +CI handles release builds. ### Windows (AMD64) ```powershell -go build -buildmode=c-shared -o plugins/windows/amd64/opencode-go-cliproxyapi.dll . +go build -tags debug -buildmode=c-shared -o plugins/windows/amd64/opencode-go-cliproxyapi.dll . ``` ### Linux (AMD64) ```bash -go build -buildmode=c-shared -o plugins/linux/amd64/opencode-go-cliproxyapi.so . +go build -tags debug -buildmode=c-shared -o plugins/linux/amd64/opencode-go-cliproxyapi.so . ``` ### macOS (ARM64) ```bash -go build -buildmode=c-shared -o plugins/darwin/arm64/opencode-go-cliproxyapi.dylib . +go build -tags debug -buildmode=c-shared -o plugins/darwin/arm64/opencode-go-cliproxyapi.dylib . ``` Place the compiled binary into your CLIProxyAPI plugin directory (e.g. `/plugins///`). @@ -81,6 +82,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 +113,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. | @@ -130,11 +132,32 @@ plugins: ```powershell # Run all tests -go test ./... +go test -tags debug ./... # Run tests with coverage -go test ./... -cover +go test -tags debug ./... -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 fd0d512..82fb76b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,33 @@ +## CLIProxyAPI v8 + +### 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 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 provider request errors. + +### Upgrade + +1. Upgrade CLIProxyAPI to v8.0.0 or later. +2. Replace the plugin binary with the new build. +3. Restart CLIProxyAPI. + +The generic endpoints are `GET /v0/management/quota/providers` and `POST /v0/management/quota/fetch`. +Both require the management key. Quota reset is unsupported. + +The management dashboard must support the generic quota API to show plugin refresh controls. +Older dashboard builds do not show these controls. + +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. + ## What's Changed Full OpenAI Codex CLI support across all models: multi-agent namespaces, custom programmatic tools (`exec`), tool call results, and reasoning efforts are fully functional. @@ -23,4 +53,4 @@ Full OpenAI Codex CLI support across all models: multi-agent namespaces, custom - 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.9...v0.1.10 \ No newline at end of file +**Full Changelog**: https://github.com/massiveits/opencode-go-cliproxyapi/compare/v0.1.9...v0.1.10 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 4934cf7..5d21da4 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 6eafe1e..cac4537 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" ) @@ -586,7 +586,6 @@ func TestFromResponsesRequest_FunctionCallOutputArray(t *testing.T) { } } - func TestBuildRequestResponsesVariants(t *testing.T) { t.Run("empty string input yields no messages", func(t *testing.T) { m := mustBuild(t, "openai-response", `{"input":""}`, nil) @@ -1010,4 +1009,3 @@ func TestResponsesReasoningEffortPassthroughWithoutValidation(t *testing.T) { t.Fatalf("reasoning_effort = %v, want xhigh", m["reasoning_effort"]) } } - diff --git a/internal/adapter/messages/request.go b/internal/adapter/messages/request.go index 9d93b22..65eb36e 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 928dc03..736310f 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" @@ -493,7 +493,6 @@ func TestFromResponsesRequest_FunctionCallOutputArray(t *testing.T) { } } - func TestResponsesReasoningOmittedWhenEmptySummary(t *testing.T) { m, eErr := respReq(t, `{"input":[{"type":"reasoning","summary":[]}]}`) if eErr != nil { diff --git a/internal/adapter/responses/request.go b/internal/adapter/responses/request.go index 6e69df7..5f9b90c 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 8786d9e..2c970a7 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 fbaaf60..d6b5b1d 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 fa573ec..2c258ca 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 057605c..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.10" + 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...

-
- - - -