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...

-
- - - -