diff --git a/README.md b/README.md index b62a393..fb5096d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ This plugin exposes OpenCode Go as a single provider (`opencode-go`) backed by a - **Dynamic Catalog Discovery**: Fetches remote model catalogs with local fallback and custom route overrides. - **Multi-Key Auth Scheduling**: Pools multiple API keys with CLIProxyAPI's native scheduler for rotation, retries, and error cooldowns across all protocols. - **OpenCode Go Quota Page**: Management Center includes a separate `OpenCode Go Quota` page. Page load lists credentials without contacting OpenCode; each card is refreshed manually and independently, and quota values do not affect routing or CPA's native quota page. +- **Native Quota Provider**: On CLIProxyAPI `v7.2.159+` (including v8), the plugin also registers as a quota provider, so each OpenCode Go credential reports `supports_quota` in `/v0/management/auth-files` and its rolling (reported as the `5h` window), weekly, and monthly windows are served by `POST /v0/management/quota/fetch` (v8 Management API: `POST /v8/management/plugins/opencode-go-cliproxyapi/quota`). Quota reset is unsupported. Older hosts ignore the capability and keep using the separate page. ## Requirements diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fd0d512..24bb764 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,10 @@ Full OpenAI Codex CLI support across all models: multi-agent namespaces, custom programmatic tools (`exec`), tool call results, and reasoning efforts are fully functional. +### Features + +- Register as a native CLIProxyAPI quota provider: on hosts `v7.2.159+` (including v8), OpenCode Go credentials report `supports_quota` in `/v0/management/auth-files` and serve rolling, weekly, and monthly windows through `POST /v0/management/quota/fetch`. The plugin keeps schema version 3, so older v7 hosts still load it and keep using the separate `OpenCode Go Quota` page. Quota lookups are now capped at 30 seconds. + ### Bug Fixes - Fix missing Responses stream item lifecycle completion events (output_text.done, content_part.done, function_call_arguments.done, output_item.done) prior to response.completed, resolving dropped assistant output and tool calls in OpenAI Codex CLI and strict Responses clients. diff --git a/internal/plugin/native_quota.go b/internal/plugin/native_quota.go new file mode 100644 index 0000000..c703742 --- /dev/null +++ b/internal/plugin/native_quota.go @@ -0,0 +1,114 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// Native quota RPC (CLIProxyAPI v7.2.159+), declared locally so the plugin +// keeps its v7.2.138 schema version and still loads on older hosts, which +// ignore the quota_provider capability. +const ( + methodQuotaIdentifier = "quota.identifier" + methodQuotaDescribe = "quota.describe" + methodQuotaFetch = "quota.fetch" + methodQuotaReset = "quota.reset" +) + +const quotaDisplayName = "OpenCode Go" + +type nativeQuotaFetchRequest struct { + Provider string `json:"provider"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +type nativeQuotaDescribeResponse struct { + SupportedProviders []string `json:"supported_providers,omitempty"` + DisplayName string `json:"display_name,omitempty"` + SupportsReset bool `json:"supports_reset,omitempty"` +} + +type nativeQuotaSubscription struct { + Plan string `json:"plan,omitempty"` +} + +type nativeQuotaBucket struct { + Window string `json:"window,omitempty"` + RemainingFraction float64 `json:"remainingFraction"` + ResetTime string `json:"resetTime,omitempty"` +} + +type nativeQuotaGroup struct { + DisplayName string `json:"displayName,omitempty"` + Buckets []nativeQuotaBucket `json:"buckets,omitempty"` +} + +type nativeQuotaFetchResponse struct { + Subscription *nativeQuotaSubscription `json:"subscription,omitempty"` + Groups []nativeQuotaGroup `json:"groups,omitempty"` +} + +func (m *Manager) handleNativeQuota(method string, request []byte) []byte { + switch method { + case methodQuotaIdentifier: + return okEnvelope(map[string]string{"identifier": ProviderID}) + case methodQuotaDescribe: + return okEnvelope(nativeQuotaDescribeResponse{SupportedProviders: []string{ProviderID}, DisplayName: quotaDisplayName}) + case methodQuotaFetch: + var req nativeQuotaFetchRequest + if json.Unmarshal(request, &req) != nil { + return ErrEnvelope("invalid_request", "malformed quota fetch request body") + } + resp, err := m.fetchNativeQuota(context.Background(), req) + if err != nil { + return ErrEnvelope("quota_failure", err.Error()) + } + return okEnvelope(resp) + default: + return ErrEnvelope("unsupported", "OpenCode Go does not support quota resets") + } +} + +// fetchNativeQuota uses the credential the host selected, never a config key. +func (m *Manager) fetchNativeQuota(ctx context.Context, req nativeQuotaFetchRequest) (nativeQuotaFetchResponse, error) { + if req.Provider != ProviderID { + return nativeQuotaFetchResponse{}, fmt.Errorf("unsupported quota provider") + } + key := strings.TrimSpace(req.Attributes["api_key"]) + if key == "" { + return nativeQuotaFetchResponse{}, fmt.Errorf("quota credential has no api key") + } + m.mu.RLock() + baseURL, timeout := m.cfg.BaseURL, m.cfg.RequestTimeout + m.mu.RUnlock() + usage, err := fetchQuota(ctx, m.bridge, baseURL, timeout, key) + if err != nil { + return nativeQuotaFetchResponse{}, err + } + return nativeQuotaFromUsage(usage) +} + +func nativeQuotaFromUsage(usage quotaUsage) (nativeQuotaFetchResponse, error) { + // OpenCode Go's rolling window spans five hours. + windows := []struct { + name string + window quotaWindow + }{{"5h", usage.Rolling}, {"weekly", usage.Weekly}, {"monthly", usage.Monthly}} + buckets := make([]nativeQuotaBucket, 0, len(windows)) + for _, w := range windows { + if w.window.Status == "" && w.window.ResetsAt == "" { + continue + } + percent := min(max(w.window.Percent, 0), 100) + buckets = append(buckets, nativeQuotaBucket{Window: w.name, RemainingFraction: float64(100-percent) / 100, ResetTime: w.window.ResetsAt}) + } + if len(buckets) == 0 { + return nativeQuotaFetchResponse{}, fmt.Errorf("quota response has no readings") + } + return nativeQuotaFetchResponse{ + Subscription: &nativeQuotaSubscription{Plan: "Go"}, + Groups: []nativeQuotaGroup{{DisplayName: quotaDisplayName, Buckets: buckets}}, + }, nil +} diff --git a/internal/plugin/native_quota_test.go b/internal/plugin/native_quota_test.go new file mode 100644 index 0000000..9e24b41 --- /dev/null +++ b/internal/plugin/native_quota_test.go @@ -0,0 +1,141 @@ +package plugin + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + + "opencode-go-cliproxyapi/internal/config" +) + +func TestNativeQuotaRegistrationKeepsLegacySchema(t *testing.T) { + var registration struct { + SchemaVersion uint32 `json:"schema_version"` + Capabilities map[string]any `json:"capabilities"` + } + decodeResult(t, mustHandle(t, NewManager(nil), pluginabi.MethodPluginRegister, lifecycleRequestBody(testValidYAML)), ®istration) + // Hosts before v7.2.159 reject plugins whose schema is newer than 3. + if registration.SchemaVersion != 3 { + t.Fatalf("schema_version = %d, want 3", registration.SchemaVersion) + } + if registration.Capabilities["quota_provider"] != true || registration.Capabilities["management_api"] != true { + t.Fatalf("capabilities = %+v", registration.Capabilities) + } +} + +func TestNativeQuotaIdentifierAndDescribe(t *testing.T) { + m := NewManager(nil) + var id struct{ Identifier string } + decodeResult(t, mustHandle(t, m, methodQuotaIdentifier, []byte(`{}`)), &id) + if id.Identifier != ProviderID { + t.Fatalf("identifier = %q", id.Identifier) + } + var desc nativeQuotaDescribeResponse + decodeResult(t, mustHandle(t, m, methodQuotaDescribe, []byte(`{}`)), &desc) + if len(desc.SupportedProviders) != 1 || desc.SupportedProviders[0] != ProviderID || desc.SupportsReset { + t.Fatalf("describe = %+v", desc) + } +} + +func TestNativeQuotaFetchUsesSelectedCredential(t *testing.T) { + const selected = "native-selected-secret" + f := &fakeCaller{responder: func(method string, payload []byte) ([]byte, error) { + if method != pluginabi.MethodHostHTTPDo { + return hostOK(map[string]any{}), nil + } + var wire struct { + URL string `json:"url"` + Headers http.Header `json:"headers"` + } + if err := json.Unmarshal(payload, &wire); err != nil || wire.URL != "https://quota.test/v1/usage" || wire.Headers.Get("Authorization") != "Bearer "+selected { + t.Fatalf("bad quota request: %s", payload) + } + return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"usage":{"rolling":{"status":"ok","percent":0,"resetsAt":"2026-10-04T15:26:49.000Z"},"weekly":{"status":"ok","percent":4,"resetsAt":"2026-10-05T00:00:00.000Z"},"monthly":{"status":"ok","percent":41,"resetsAt":"2026-10-26T10:20:31.000Z"}}}`)}), nil + }} + m := NewManager(NewHostBridge(f.call)) + m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout, APIKeys: []config.APIKey{{Value: "config-default-key"}}} + raw := mustHandle(t, m, methodQuotaFetch, []byte(`{"auth_index":"1","provider":"opencode-go","attributes":{"api_key":"`+selected+`"},"host_callback_id":"cb-1"}`)) + var wire map[string]any + decodeResult(t, raw, &wire) + bucket := wire["groups"].([]any)[0].(map[string]any)["buckets"].([]any)[1].(map[string]any) + if wire["subscription"].(map[string]any)["plan"] != "Go" || bucket["window"] != "weekly" || bucket["remainingFraction"] != 0.96 || bucket["resetTime"] != "2026-10-05T00:00:00.000Z" { + t.Fatalf("wire response = %+v", wire) + } + var got nativeQuotaFetchResponse + decodeResult(t, raw, &got) + if got.Subscription == nil || got.Subscription.Plan != "Go" || len(got.Groups) != 1 { + t.Fatalf("response = %+v", got) + } + want := []nativeQuotaBucket{ + {Window: "5h", RemainingFraction: 1, ResetTime: "2026-10-04T15:26:49.000Z"}, + {Window: "weekly", RemainingFraction: 0.96, ResetTime: "2026-10-05T00:00:00.000Z"}, + {Window: "monthly", RemainingFraction: 0.59, ResetTime: "2026-10-26T10:20:31.000Z"}, + } + buckets := got.Groups[0].Buckets + if len(buckets) != len(want) { + t.Fatalf("buckets = %+v", buckets) + } + for i := range want { + if buckets[i] != want[i] { + t.Fatalf("bucket %d = %+v, want %+v", i, buckets[i], want[i]) + } + } +} + +func TestNativeQuotaSkipsMissingWindowsAndClampsPercent(t *testing.T) { + got, err := nativeQuotaFromUsage(quotaUsage{Weekly: quotaWindow{Status: "ok", Percent: 130}}) + if err != nil || len(got.Groups[0].Buckets) != 1 || got.Groups[0].Buckets[0] != (nativeQuotaBucket{Window: "weekly", RemainingFraction: 0}) { + t.Fatalf("response = %+v err=%v", got, err) + } + if _, err := nativeQuotaFromUsage(quotaUsage{}); err == nil { + t.Fatal("empty usage did not fail") + } +} + +func TestNativeQuotaFailuresAreRedacted(t *testing.T) { + const key = "native-error-secret" + f := &fakeCaller{responder: func(string, []byte) ([]byte, error) { + return hostOK(pluginapi.HTTPResponse{StatusCode: http.StatusUnauthorized, Body: []byte(key + " upstream body")}), nil + }} + m := NewManager(NewHostBridge(f.call)) + m.cfg = config.Config{BaseURL: "https://quota.test/v1", RequestTimeout: config.DefaultRequestTimeout} + for name, body := range map[string]string{ + "upstream": `{"provider":"opencode-go","attributes":{"api_key":"` + key + `"}}`, + "provider": `{"provider":"codex","attributes":{"api_key":"` + key + `"}}`, + "no key": `{"provider":"opencode-go"}`, + "json": `{`, + } { + t.Run(name, func(t *testing.T) { + resp := string(mustHandle(t, m, methodQuotaFetch, []byte(body))) + if !strings.Contains(resp, `"ok":false`) || strings.Contains(resp, key) || strings.Contains(resp, "upstream body") { + t.Fatalf("response = %s", resp) + } + }) + } +} + +func TestQuotaTimeoutIsBounded(t *testing.T) { + for in, want := range map[time.Duration]time.Duration{ + 0: maxQuotaTimeout, + config.DefaultRequestTimeout: maxQuotaTimeout, + 5 * time.Second: 5 * time.Second, + maxQuotaTimeout: maxQuotaTimeout, + -time.Second: maxQuotaTimeout, + } { + if got := quotaTimeout(in); got != want { + t.Fatalf("quotaTimeout(%s) = %s, want %s", in, got, want) + } + } +} + +func TestNativeQuotaResetIsUnsupported(t *testing.T) { + resp := string(mustHandle(t, NewManager(nil), methodQuotaReset, []byte(`{}`))) + if !strings.Contains(resp, `"ok":false`) || !strings.Contains(resp, "unsupported") { + t.Fatalf("response = %s", resp) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 057605c..43598cc 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -128,6 +128,8 @@ func (m *Manager) HandleCall(method string, request []byte) (resp []byte, err er return m.registerManagement(request) case pluginabi.MethodManagementHandle: return m.handleManagement(request) + case methodQuotaIdentifier, methodQuotaDescribe, methodQuotaFetch, methodQuotaReset: + return m.handleNativeQuota(method, request), nil case pluginabi.MethodExecutorIdentifier: return okEnvelope(map[string]string{"identifier": ProviderID}), nil case pluginabi.MethodExecutorCountTokens: @@ -160,6 +162,7 @@ type capabilities struct { ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"` ManagementAPI bool `json:"management_api"` + QuotaProvider bool `json:"quota_provider"` } type registrationResult struct { @@ -242,6 +245,7 @@ func registrationEnvelope() []byte { ExecutorInputFormats: formats, ExecutorOutputFormats: formats, ManagementAPI: true, + QuotaProvider: true, }, }) } diff --git a/internal/plugin/quota.go b/internal/plugin/quota.go index cf069f4..e92a66f 100644 --- a/internal/plugin/quota.go +++ b/internal/plugin/quota.go @@ -109,11 +109,21 @@ func quotaJSON(v any) (pluginapi.ManagementResponse, error) { return pluginapi.ManagementResponse{Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: body}, nil } +// maxQuotaTimeout bounds quota lookups, which serve interactive management calls. +const maxQuotaTimeout = 30 * time.Second + +func quotaTimeout(requestTimeout time.Duration) time.Duration { + if requestTimeout <= 0 || requestTimeout > maxQuotaTimeout { + return maxQuotaTimeout + } + return requestTimeout +} + func fetchQuota(ctx context.Context, bridge *HostBridge, baseURL string, timeout time.Duration, key string) (quotaUsage, error) { if bridge == nil { return quotaUsage{}, fmt.Errorf("quota bridge unavailable") } - ctx, cancel := context.WithTimeout(ctx, timeout) + ctx, cancel := context.WithTimeout(ctx, quotaTimeout(timeout)) defer cancel() resp, err := bridge.Do(ctx, pluginapi.HTTPRequest{Method: http.MethodGet, URL: strings.TrimRight(baseURL, "/") + "/usage", Headers: http.Header{"Authorization": []string{"Bearer " + key}, "Accept": []string{"application/json"}}}) if err != nil || resp.StatusCode != http.StatusOK {