Skip to content
Closed
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
52 changes: 48 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}"

Expand Down Expand Up @@ -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 `<prefix>/<model>`. When `false`, uses bare model IDs. |
Expand All @@ -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
Expand All @@ -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":"<selected 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.
40 changes: 20 additions & 20 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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
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.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/chatcompletions/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/chatcompletions/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/messages/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/messages/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/responses/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/responses/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion internal/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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},
Expand Down
11 changes: 7 additions & 4 deletions internal/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type ModelPrefix struct {
}

type APIKey struct {
Name string
Value string
}

Expand Down Expand Up @@ -82,6 +83,7 @@ type rawPrefix struct {
}

type rawKey struct {
Name string `yaml:"name"`
Value string `yaml:"value"`
}

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/errclass/errclass.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/errclass/errclass_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
31 changes: 27 additions & 4 deletions internal/plugin/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
}
Expand All @@ -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"
}
4 changes: 2 additions & 2 deletions internal/plugin/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions internal/plugin/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions internal/plugin/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions internal/plugin/hostbridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading