diff --git a/README.md b/README.md index b62a393..9f35f1d 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,12 @@ This plugin exposes OpenCode Go as a single provider (`opencode-go`) backed by a - **Thinking & Reasoning Support**: Maps reasoning effort across supported client and upstream formats. - **Dynamic Catalog Discovery**: Fetches remote model catalogs with local fallback and custom route overrides. - **Multi-Key Auth Scheduling**: Pools multiple API keys with CLIProxyAPI's native scheduler for rotation, retries, and error cooldowns across all protocols. -- **OpenCode Go Quota Page**: Management Center includes a separate `OpenCode Go Quota` page. Page load lists credentials without contacting OpenCode; each card is refreshed manually and independently, and quota values do not affect routing or CPA's native quota page. +- **Native quotas**: Rolling, weekly, and monthly quotas use CLIProxyAPI’s generic quota endpoints and the selected credential. A compatibility resource page is also exposed for older Management Center builds. ## Requirements -- **CLIProxyAPI**: `v7.2.138+` -- **Go Toolchain**: Go 1.24+ (with CGO enabled for C-shared build mode) +- **CLIProxyAPI**: `v8.0.0+` +- **Go Toolchain**: Go 1.26.7+ (with CGO enabled for C-shared build mode) ## Build @@ -81,6 +81,7 @@ plugins: # OpenCode Go API keys (at least one required). Supports ${ENV_VAR} expansion. api-keys: - value: "sk-opencode-key-1" + name: "Personal" # optional account label - value: "sk-opencode-key-2" - value: "${OPENCODE_GO_API_KEY}" @@ -111,7 +112,7 @@ plugins: | Option | Type | Default | Description | |---|---|---|---| -| `api-keys` | `[]object` | *(Required)* | List of API keys (`- value: "..."`). Supports `${ENV_VAR}` expansion. Duplicates and empty values are rejected. | +| `api-keys` | `[]object` | *(Required)* | List of API keys (`- value: "..."`, optional `name: "Personal"`). Supports `${ENV_VAR}` expansion. Duplicates and empty values are rejected. | | `base-url` | `string` | `https://opencode.ai/zen/go/v1` | Upstream base URL. Must be valid HTTPS (or HTTP if `allow-http: true`) without query parameters, fragments, or userinfo. | | `catalog-url` | `string` | `{base-url}/models` | Full URL for catalog discovery. Defaults to `{base-url}/models`. | | `model-prefix.enabled` | `bool` | `true` | When `true`, client-facing model names use `/`. When `false`, uses bare model IDs. | @@ -126,6 +127,28 @@ plugins: | `max-response-bytes` | `int64` | `67108864` (64 MiB) | Maximum non-streaming response body size in bytes. | | `allow-http` | `bool` | `false` | When `true`, permits `http://` scheme in `base-url` / `catalog-url` for local testing. | +### Keeping the model list aligned with OpenCode Go + +The plugin replaces its `opencode-go` catalog with the latest successful +response from OpenCode Go. Models removed upstream therefore disappear from +the next successful refresh. The refresh interval is configurable, with a +minimum of one minute. + +For a strict mirror, use a short interval and fail closed during an outage: + +```yaml +catalog: + refresh-interval: "1m" + stale-while-unavailable: false +``` + +`stale-while-unavailable: true` is safer during a transient outage, but it +intentionally keeps the last successful model list until the next refresh. +Models whose upstream protocol is not known to the plugin are retained in +refresh diagnostics and excluded from `opencode-go` models. Add a +`route-overrides` entry only when the upstream protocol is confirmed; the +plugin does not guess a route for an unknown model. + ## Testing ```powershell @@ -138,3 +161,24 @@ go test ./... -cover # Run linter / vetting go vet ./... ``` + +## Account names and quotas + +Set an optional `name` on each `api-keys` entry, alongside `value`. +For example, use `name: Personal` or `name: Work`. Without a name, one key +uses **OpenCode Go**; multiple keys use **OpenCode Go 1**, **OpenCode Go 2**, and subsequent numbers. +Default numbers follow configuration order. Explicit names remain stable when keys are reordered. +The usage response contains no email or account identity. The plugin does not infer an email from a key. + +New credential files have readable names. Existing auth IDs remain unchanged, and +legacy generated labels are replaced when parsed. Existing custom labels are preserved +unless configuration specifies a name. Existing filenames are retained automatically; +stop CLIProxyAPI before renaming an old file to a readable `.json` filename, and keep +its JSON `id` and all other fields unchanged. Back up the file first. + +Discover support with `GET /v0/management/quota/providers`, then call +`POST /v0/management/quota/fetch` with `{"auth_index":""}`. +These endpoints require the CLIProxyAPI management key. The normalized response contains +`subscription.plan`, `groups[].buckets[].window`, `remainingFraction`, and `resetTime`. +Missing windows are omitted. Invalid readings return an error, not an invented zero. +Quota reset is unsupported. For older Management Center builds, the plugin also exposes a compatibility quota resource page backed by the same native quota provider. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fd0d512..c5379a2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,26 +1,26 @@ ## 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. +- Support CLIProxyAPI v8 and its generic plugin quota API. +- Return rolling, weekly, and monthly quotas for the credential selected by CLIProxyAPI. +- Advertise the quota provider as **OpenCode Go**. +- Remove the separate OpenCode Go quota page and custom management routes. +- Accept an optional `name` for each API key. +- Use readable account labels and filenames instead of key digests. +- Preserve existing auth IDs, custom labels, filenames, and host metadata. +- Prevent duplicate auth records at cold start when readable filenames exist. +- Reject invalid quota values and redact upstream request errors. -### Bug Fixes +## Upgrade -- Fix missing Responses stream item lifecycle completion events (output_text.done, content_part.done, function_call_arguments.done, output_item.done) prior to response.completed, resolving dropped assistant output and tool calls in OpenAI Codex CLI and strict Responses clients. -- Fix handling of in-history messages with role: "system" across protocol adapters without rejecting them as unsupported roles or forwarding invalid turn roles to upstream providers that require alternating user/assistant turns. -- Fix handling of multi-part content arrays in function_call_output.output under /v1/responses decoding. -- Fix upstream HTTP >= 400 error propagation in streaming and non-streaming requests by reading and extracting the upstream error payload instead of discarding it with generic fallback errors. -- Add two-way Responses tool namespace and additional_tools translation: merge dynamic declarations from input items (type: "additional_tools"), unroll client-side grouping tools (type: "namespace") into qualified wire names for upstream models, and restore original tool names and namespaces across non-streaming output and streaming SSE events for OpenAI Codex CLI and multi-agent harnesses. -- Add Codex custom tool normalization and hosted tool dropping: rewrite generic custom tools (`type: "custom"`, e.g. `exec`) into function tools with default object schemas, and drop client-only/hosted tools (`apply_patch`, `web_search`, `web_search_preview`, `tool_search`, `image_generation`) when routing to Chat Completions or Messages endpoints, resolving Codex CLI failures on models like `space-bunny-free`. -- Add bidirectional support for `custom_tool_call` and `custom_tool_call_output` conversation items and outbound custom tool events: translate inbound custom tool calls and results across Chat Completions and Messages protocols, unwrap arguments into clean raw input, and restore `custom_tool_call` and `response.custom_tool_call_input.done` on outbound non-streaming and streaming responses, resolving Codex tool dispatch aborts on programmatic tools like `exec`. -- Remove local reasoning effort validation across request translators: allow client-declared reasoning effort levels (e.g. `xhigh`, `max`, and unlisted levels) to pass through transparently to upstream endpoints without local gatekeeping in `chatcompletions`, `responses`, and `messages` adapters. -- Normalize tools for non-GPT native Responses models: unroll namespaces, convert custom tools (`exec`) into function tools, and sanitize `web_search` definitions when targeting non-GPT Responses endpoints (e.g. `muse-spark`, `grok`), while preserving transparent passthrough for native GPT models (e.g. `gpt-6-luna`). -- Restore custom tool calls across native Responses-to-Responses streaming and non-streaming adapters, ensure historical function_call arguments default to valid JSON via `shared.DefaultArgs` to resolve Muse Spark errors, drop compaction input items to fix Grok compaction blob decode failures, and expand `UnwrapCustomToolInput` to unwrap arguments, code, cmd, and command fields. -- Drop historical `reasoning` input items for non-GPT Responses upstreams: resolve Grok HTTP 400 "Could not decode the compaction blob" decryption errors on multi-turn conversations caused by pooled account credential mismatches on encrypted reasoning blobs. +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. A management dashboard must support the generic +quota API to show plugin refresh controls. Backend support alone does not +add those controls to older dashboard builds. -## Upgrade Notes - -- Replace the old plugin binary with the new release binary. -- Restart CLIProxyAPI after replacing the plugin. -- Hard-refresh Management Center if the plugin page looks stale. - -**Full Changelog**: https://github.com/massiveits/opencode-go-cliproxyapi/compare/v0.1.9...v0.1.10 \ No newline at end of file +Existing credential files keep their names and stable IDs. Set `name:` on an +API key to choose its display label. New files use readable names. diff --git a/go.mod b/go.mod index 69103b2..4043b3e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module opencode-go-cliproxyapi go 1.26.7 require ( - github.com/router-for-me/CLIProxyAPI/v7 v7.2.138 + github.com/router-for-me/CLIProxyAPI/v8 v8.0.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index db1f7e0..7fcb686 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/router-for-me/CLIProxyAPI/v7 v7.2.138 h1:RbMi2wh0cOzvTq3O1vooSHz9FFvCvP5yHd3Aganjk5g= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.138/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= +github.com/router-for-me/CLIProxyAPI/v8 v8.0.0 h1:UfV1dhI+319cCquIKqY3c5VpJCP81Y052PPKCIhx0l0= +github.com/router-for-me/CLIProxyAPI/v8 v8.0.0/go.mod h1:j1Mtcj0TrC66O1JwbSBB03lY3uYPx4J7yHNvU6nDBCo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/adapter/chatcompletions/request.go b/internal/adapter/chatcompletions/request.go index 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..e617d97 100644 --- a/internal/adapter/chatcompletions/request_test.go +++ b/internal/adapter/chatcompletions/request_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/errclass" ) diff --git a/internal/adapter/messages/request.go b/internal/adapter/messages/request.go index 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..e4300b5 100644 --- a/internal/adapter/messages/request_test.go +++ b/internal/adapter/messages/request_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" "opencode-go-cliproxyapi/internal/adapter/shared" "opencode-go-cliproxyapi/internal/errclass" diff --git a/internal/adapter/responses/request.go b/internal/adapter/responses/request.go index 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..7be9b73 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" ) @@ -121,6 +121,10 @@ var prefixRoutes = []struct { route Route }{ {"muse-spark", RouteResponses}, + // OpenCode Go's Space Bunny family is served through the OpenAI + // Chat Completions protocol. Keep this before any broader future + // prefixes so variants such as space-bunny-free resolve consistently. + {"space-bunny", RouteChatCompletions}, {"deepseek", RouteChatCompletions}, {"minimax", RouteMessages}, {"longcat", RouteChatCompletions}, diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index c666d1e..954a5e2 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" ) @@ -199,9 +199,12 @@ func TestRoutePriorityMatrix(t *testing.T) { func TestCompatExactIDs(t *testing.T) { cases := map[string]Route{ // Responses. - "grok-4.5": RouteResponses, - "gpt-5.6-luna": RouteResponses, - "muse-spark-1.2-contributor": RouteResponses, + "grok-4.5": RouteResponses, + "gpt-5.6-luna": RouteResponses, + "muse-spark-1.2-contributor": RouteResponses, + "muse-spark-1.3-contributor": RouteResponses, + "muse-spark-1.3-contributor-free": RouteResponses, + "space-bunny-free": RouteChatCompletions, // Chat Completions. "glm-5.3": RouteChatCompletions, "glm-5.2": RouteChatCompletions, 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/management.go b/internal/plugin/management.go new file mode 100644 index 0000000..b00b4d4 --- /dev/null +++ b/internal/plugin/management.go @@ -0,0 +1,163 @@ +package plugin + +// This compatibility resource keeps the quota page usable with Management +// Center builds that predate CLIProxyAPI v8's generic quota UI. The native +// quota provider remains the source of truth; this route only adapts its +// response to the legacy embedded page. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" + + "opencode-go-cliproxyapi/internal/config" + "opencode-go-cliproxyapi/resources" +) + +type legacyQuotaWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt string `json:"resets_at"` +} + +type legacyQuotaUsage struct { + Rolling legacyQuotaWindow `json:"rolling"` + Weekly legacyQuotaWindow `json:"weekly"` + Monthly legacyQuotaWindow `json:"monthly"` +} + +type legacyQuotaCard struct { + KeyID string `json:"key_id"` + Label string `json:"label"` + Usage *legacyQuotaUsage `json:"usage,omitempty"` +} + +type legacyQuotaList struct { + Cards []legacyQuotaCard `json:"cards"` +} + +type legacyQuotaRequest struct { + KeyID string `json:"key_id"` +} + +func quotaIdentity(key string) (id, label string) { + digest := sha256.Sum256([]byte(key)) + hash := hex.EncodeToString(digest[:]) + return "opencode-go-key-" + hash, "OpenCode Go credential " + hash[:12] +} + +func (m *Manager) registerManagement(request []byte) ([]byte, error) { + var req struct { + Plugin pluginapi.Metadata `json:"Plugin"` + BasePath string `json:"BasePath"` + ResourceBasePath string `json:"ResourceBasePath"` + } + if err := json.Unmarshal(request, &req); err != nil { + return ErrEnvelope("invalid_request", "malformed management registration request body"), nil + } + return okEnvelope(struct { + Routes []struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"routes"` + Resources []struct { + Path string `json:"path"` + Menu string `json:"menu"` + Description string `json:"description"` + } `json:"resources"` + }{ + Routes: []struct { + Method string `json:"method"` + Path string `json:"path"` + }{{Method: http.MethodPost, Path: "/plugins/" + pluginName + "/quota-usage"}}, + Resources: []struct { + Path string `json:"path"` + Menu string `json:"menu"` + Description string `json:"description"` + }{{Path: "/quota", Menu: "OpenCode Go Quota", Description: "View OpenCode Go quota windows."}}, + }), nil +} + +func (m *Manager) handleManagement(request []byte) ([]byte, error) { + var req struct { + pluginapi.ManagementRequest + HostCallbackID string `json:"host_callback_id,omitempty"` + } + if err := json.Unmarshal(request, &req); err != nil { + return ErrEnvelope("invalid_request", "malformed management request body"), nil + } + resp, err := m.HandleManagement(context.Background(), req.ManagementRequest) + if err != nil { + return ErrEnvelope("management_failure", err.Error()), nil + } + raw, _ := json.Marshal(resp) + return okEnvelope(json.RawMessage(raw)), nil +} + +func (m *Manager) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { + if req.Method == http.MethodGet && req.Path == "/v0/resource/plugins/"+pluginName+"/quota" { + return pluginapi.ManagementResponse{ + Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, + Body: []byte(resources.QuotaPage), + }, nil + } + if req.Method != http.MethodPost || req.Path != "/v0/management/plugins/"+pluginName+"/quota-usage" { + return pluginapi.ManagementResponse{StatusCode: http.StatusNotFound, Body: []byte(`{"error":"not found"}`)}, nil + } + var body legacyQuotaRequest + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &body); err != nil { + return pluginapi.ManagementResponse{StatusCode: http.StatusBadRequest, Body: []byte(`{"error":"invalid request"}`)}, nil + } + } + m.mu.RLock() + keys := append([]config.APIKey(nil), m.cfg.APIKeys...) + m.mu.RUnlock() + if body.KeyID == "" { + cards := make([]legacyQuotaCard, 0, len(keys)) + for _, key := range keys { + id, label := quotaIdentity(key.Value) + cards = append(cards, legacyQuotaCard{KeyID: id, Label: label}) + } + return quotaJSON(legacyQuotaList{Cards: cards}) + } + for _, key := range keys { + id, label := quotaIdentity(key.Value) + if id != body.KeyID { + continue + } + fresh, err := m.FetchQuota(ctx, pluginapi.QuotaFetchRequest{ + Provider: ProviderID, + Attributes: map[string]string{"api_key": key.Value}, + }) + if err != nil || len(fresh.Groups) == 0 { + return pluginapi.ManagementResponse{StatusCode: http.StatusBadGateway, Body: []byte(`{"error":"quota refresh failed"}`)}, nil + } + usage := legacyQuotaUsage{} + for _, bucket := range fresh.Groups[0].Buckets { + window := legacyQuotaWindow{Status: "ok", Percent: (1 - bucket.RemainingFraction) * 100, ResetsAt: bucket.ResetTime} + switch bucket.Window { + case "rolling": + usage.Rolling = window + case "weekly": + usage.Weekly = window + case "monthly": + usage.Monthly = window + } + } + return quotaJSON(legacyQuotaCard{KeyID: id, Label: label, Usage: &usage}) + } + return pluginapi.ManagementResponse{StatusCode: http.StatusNotFound, Body: []byte(`{"error":"unknown quota key"}`)}, nil +} + +func quotaJSON(value any) (pluginapi.ManagementResponse, error) { + body, err := json.Marshal(value) + if err != nil { + return pluginapi.ManagementResponse{}, err + } + return pluginapi.ManagementResponse{Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: body}, nil +} diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go new file mode 100644 index 0000000..f1fbb4e --- /dev/null +++ b/internal/plugin/management_test.go @@ -0,0 +1,92 @@ +package plugin + +import ( + "context" + "encoding/json" + "math" + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" + + "opencode-go-cliproxyapi/internal/config" +) + +func TestManagementRegistrationIncludesQuotaResource(t *testing.T) { + m := NewManager(nil) + var got struct { + Routes []struct{ Method, Path string } `json:"routes"` + Resources []struct{ Path, Menu, Description string } `json:"resources"` + } + decodeResult(t, mustHandle(t, m, pluginabi.MethodManagementRegister, []byte(`{}`)), &got) + if len(got.Routes) != 1 || got.Routes[0].Method != http.MethodPost || got.Routes[0].Path != "/plugins/"+pluginName+"/quota-usage" { + t.Fatalf("routes = %+v", got.Routes) + } + if len(got.Resources) != 1 || got.Resources[0].Path != "/quota" || got.Resources[0].Menu != "OpenCode Go Quota" { + t.Fatalf("resources = %+v", got.Resources) + } + var registration registrationResult + decodeResult(t, registrationEnvelope(), ®istration) + if !registration.Capabilities.ManagementAPI { + t.Fatal("registration did not advertise management_api") + } +} + +func TestLegacyQuotaResourceUsesNativeQuotaProvider(t *testing.T) { + const key = "legacy-quota-key" + f := &fakeCaller{responder: func(method string, payload []byte) ([]byte, error) { + if method != pluginabi.MethodHostHTTPDo { + return hostOK(map[string]any{}), nil + } + var req pluginapi.HTTPRequest + if err := json.Unmarshal(payload, &req); err != nil { + t.Fatal(err) + } + if req.URL != "https://quota.test/v1/usage" || req.Headers.Get("Authorization") != "Bearer "+key { + t.Fatalf("unexpected quota request: %+v", req) + } + return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"usage":{"rolling":{"percent":12,"resetsAt":"2026-10-05T06:00:00Z"},"weekly":{"percent":8},"monthly":{"percent":5}}}`)}), nil + }} + m := NewManager(NewHostBridge(f.call)) + m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: key}}} + id, _ := quotaIdentity(key) + list, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{ + Method: http.MethodPost, + Path: "/v0/management/plugins/" + pluginName + "/quota-usage", + Body: []byte(`{}`), + }) + if err != nil || list.StatusCode != 0 { + t.Fatalf("quota list response = %+v err=%v", list, err) + } + var listed legacyQuotaList + if err := json.Unmarshal(list.Body, &listed); err != nil { + t.Fatal(err) + } + if len(listed.Cards) != 1 || listed.Cards[0].KeyID != id { + t.Fatalf("listed cards = %+v", listed.Cards) + } + resp, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{ + Method: http.MethodPost, + Path: "/v0/management/plugins/" + pluginName + "/quota-usage", + Body: []byte(`{"key_id":"` + id + `"}`), + }) + if err != nil || resp.StatusCode != 0 { + t.Fatalf("quota response = %+v err=%v", resp, err) + } + var card legacyQuotaCard + if err := json.Unmarshal(resp.Body, &card); err != nil { + t.Fatal(err) + } + if card.Usage == nil || math.Abs(card.Usage.Rolling.Percent-12) > 1e-9 || math.Abs(card.Usage.Weekly.Percent-8) > 1e-9 || math.Abs(card.Usage.Monthly.Percent-5) > 1e-9 { + t.Fatalf("usage = %+v", card.Usage) + } + resource, err := m.HandleManagement(context.Background(), pluginapi.ManagementRequest{ + Method: http.MethodGet, + Path: "/v0/resource/plugins/" + pluginName + "/quota", + }) + if err != nil || resource.Headers.Get("Content-Type") != "text/html; charset=utf-8" || !strings.Contains(string(resource.Body), "OpenCode Go Quota") { + t.Fatalf("resource = %+v err=%v", resource, err) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 057605c..b1d4bf4 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 } @@ -128,6 +134,22 @@ func (m *Manager) HandleCall(method string, request []byte) (resp []byte, err er 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 +181,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 +196,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 +264,12 @@ func registrationEnvelope() []byte { ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth, ExecutorInputFormats: formats, ExecutorOutputFormats: formats, + QuotaProvider: true, ManagementAPI: 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 +349,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 +376,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 +387,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 +569,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..7fdf3a4 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.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/quota_page.html b/resources/quota_page.html index 0135974..9b4bb6e 100644 --- a/resources/quota_page.html +++ b/resources/quota_page.html @@ -1,259 +1,259 @@ - - - - - -OpenCode Go Quota - - - -
-
-
-

OpenCode Go Quota

-

Usage for each configured credential.

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

Loading credentials...

-
- - - - + + + + + +OpenCode Go Quota + + + +
+
+
+

OpenCode Go Quota

+

Usage for each configured credential.

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

Loading credentials...

+
+ + + +