Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
114 changes: 114 additions & 0 deletions internal/plugin/native_quota.go
Original file line number Diff line number Diff line change
@@ -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
}
141 changes: 141 additions & 0 deletions internal/plugin/native_quota_test.go
Original file line number Diff line number Diff line change
@@ -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)), &registration)
// 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)
}
}
4 changes: 4 additions & 0 deletions internal/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -242,6 +245,7 @@ func registrationEnvelope() []byte {
ExecutorInputFormats: formats,
ExecutorOutputFormats: formats,
ManagementAPI: true,
QuotaProvider: true,
},
})
}
Expand Down
12 changes: 11 additions & 1 deletion internal/plugin/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down