Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,10 @@
# of /chat/completions (default: qwen3.7-max). Temporary manual split until
# upstream exposes per-model endpoint metadata.
# OPENCODE_GO_MESSAGES_MODELS=qwen3.7-max
# Reasoning effort injected when a client sends no reasoning parameter
# (default: low). Some models always think and reject requests that omit it.
# Accepts low, high, max; set to "none" (or "off") to inject nothing.
# OPENCODE_GO_DEFAULT_REASONING_EFFORT=low

# Azure OpenAI
# AZURE_API_KEY=...
Expand Down
39 changes: 39 additions & 0 deletions docs/providers/opencode-go.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,45 @@ OPENCODE_GO_MESSAGES_MODELS=qwen3.7-max
once the upstream model list distinguishes endpoints.
</Note>

## Reasoning

Some OpenCode Zen models always think and reject requests that leave the
reasoning parameter out:

```text
[1210] This model always engages in thinking and cannot be disabled; please use low, high, or max
```

GoModel therefore sends `reasoning_effort: "low"` on `/chat/completions` when a
request carries no reasoning parameter at all. Models that ignore the parameter
are unaffected. Raise the default per deployment:

```bash
OPENCODE_GO_DEFAULT_REASONING_EFFORT=high
```

Set it to `none` (or `off`) to inject nothing and forward requests untouched.

Clients keep control when they ask for reasoning themselves: nothing is injected
over an explicit `reasoning.effort` or a top-level `reasoning_effort`. GoModel's
recognized effort levels are mapped to their nearest equivalent in the low /
high / max set OpenCode Zen accepts:

| Requested effort | Sent upstream |
| ------------------------ | ------------- |
| `none`, `minimal`, `low` | `low` |
| `medium` | `low` |
| `high` | `high` |
| `xhigh`, `max` | `max` |

Any other value (`turbo`, say) is forwarded unchanged for the upstream to judge.
`reasoning.effort` is GoModel's canonical field, so a request that sets both it
and a top-level `reasoning_effort` is sent with the mapped `reasoning.effort` —
the same precedence every other GoModel provider applies.

Models routed to `/messages` use the Anthropic thinking dialect instead and are
not affected by this mapping.

## Not supported

- Embeddings (returns `invalid_request_error`).
7 changes: 5 additions & 2 deletions docs/providers/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,11 @@ support, not every individual model capability exposed by an upstream provider.
- **OpenCode Go (OpenCode Zen)** — routes per model: most models use
OpenAI-style `/chat/completions`, while `/messages`-only models (default
`qwen3.7-max`, override with `OPENCODE_GO_MESSAGES_MODELS`) are sent to the
Anthropic-native endpoint. Set `OPENCODE_GO_API_KEY`; the base URL defaults to
`https://opencode.ai/zen/go/v1`.
Anthropic-native endpoint. Chat requests without a reasoning parameter get
`reasoning_effort: "low"` injected, because some models always think and
reject requests that omit it (override with
`OPENCODE_GO_DEFAULT_REASONING_EFFORT`). Set `OPENCODE_GO_API_KEY`; the base
URL defaults to `https://opencode.ai/zen/go/v1`.
- **ChatGPT subscription** — serves `/v1/responses` only, billed against the
ChatGPT plan's quota rather than API credit. The upstream accepts a strict
parameter allowlist and streams only; GoModel adapts requests and collapses
Expand Down
22 changes: 14 additions & 8 deletions internal/providers/opencodego/opencodego.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,7 @@ var _ core.Provider = (*Provider)(nil)
// New creates a new OpenCode Go provider.
func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider {
baseURL := providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL)
chat := openai.NewChatCompatible(cfg.APIKey, opts, openai.CompatibleProviderConfig{
ProviderName: "opencode_go",
BaseURL: baseURL,
})
chat := openai.NewChatCompatible(cfg.APIKey, opts, compatibleConfig(baseURL))
// opts carries the shared keyring, so the /messages client rotates in step
// with the chat client above rather than pinning the primary key.
messages := anthropic.New(providers.ProviderConfig{APIKey: cfg.APIKey, APIKeys: cfg.APIKeys, BaseURL: baseURL}, opts)
Expand All @@ -90,10 +87,7 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov
// If httpClient is nil, http.DefaultClient is used.
func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider {
resolved := providers.ResolveBaseURL(baseURL, defaultBaseURL)
chat := openai.NewChatCompatibleWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{
ProviderName: "opencode_go",
BaseURL: resolved,
})
chat := openai.NewChatCompatibleWithHTTPClient(apiKey, httpClient, hooks, compatibleConfig(resolved))
messages := anthropic.NewWithHTTPClient(apiKey, httpClient, hooks)
messages.SetBaseURL(resolved)
return &Provider{
Expand All @@ -103,6 +97,18 @@ func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, h
}
}

// compatibleConfig describes the OpenAI-compatible /chat/completions half of
// the provider. The AdaptChatRequest hook carries OpenCode Zen's reasoning
// quirk (see reasoning.go), so /v1/responses picks it up through
// ResponsesViaChat as well.
func compatibleConfig(baseURL string) openai.CompatibleProviderConfig {
return openai.CompatibleProviderConfig{
ProviderName: "opencode_go",
BaseURL: baseURL,
AdaptChatRequest: adaptChatRequest(loadDefaultReasoningEffort()),
}
}

// loadMessagesModels returns the set of model IDs routed to /messages, using the
// OPENCODE_GO_MESSAGES_MODELS override when present.
func loadMessagesModels() map[string]struct{} {
Expand Down
78 changes: 78 additions & 0 deletions internal/providers/opencodego/reasoning.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package opencodego

import (
"os"
"strings"

"github.com/enterpilot/gomodel/internal/core"
"github.com/enterpilot/gomodel/internal/providers"
)

// defaultReasoningEffortEnvVar names the override for the reasoning effort
// injected when a client sends no reasoning parameter at all.
const defaultReasoningEffortEnvVar = "OPENCODE_GO_DEFAULT_REASONING_EFFORT"

// defaultReasoningEffort is injected when the client omits reasoning. Some
// OpenCode Zen models always think and reject a request that leaves the
// parameter out ("This model always engages in thinking and cannot be
// disabled; please use low, high, or max"), so an absent parameter must not
// read as "thinking off". "low" is the cheapest level those models accept;
// raise it with OPENCODE_GO_DEFAULT_REASONING_EFFORT. Models that ignore the
// parameter are unaffected.
const defaultReasoningEffort = "low"

// adaptChatRequest returns the AdaptChatRequest hook for OpenCode Zen's
// /chat/completions dialect. It maps GoModel's nested reasoning shape onto the
// top-level "reasoning_effort" string the upstream documents, and fills in
// defaultEffort when the client asked for no reasoning at all. An empty
// defaultEffort disables injection.
func adaptChatRequest(defaultEffort string) func(*core.ChatRequest) (*core.ChatRequest, error) {
return func(req *core.ChatRequest) (*core.ChatRequest, error) {
if req == nil {
return req, nil
}
// GoModel's nested reasoning.effort is the canonical field: as with
// every other provider using AdaptReasoningEffortRequest, it wins over
// a flat reasoning_effort the client sent alongside it.
if req.Reasoning != nil && strings.TrimSpace(req.Reasoning.Effort) != "" {
return providers.AdaptReasoningEffortRequest(req, normalizeReasoningEffort(req.Reasoning.Effort))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
SantiagoDePolonia marked this conversation as resolved.
}
// Nothing canonical to map: a client that speaks the flat wire shape
// asked for reasoning too, so the default must not overwrite it.
if defaultEffort == "" || req.ExtraFields.Lookup("reasoning_effort") != nil {
return req, nil
}
return providers.AdaptReasoningEffortRequest(req, defaultEffort)
}
}

// normalizeReasoningEffort maps GoModel's effort levels onto the low/high/max
// set OpenCode Zen accepts, downgrading the levels it does not know to their
// nearest supported equivalent. "none" becomes "low" because the models that
// enforce this cannot turn thinking off. Values outside GoModel's vocabulary
// pass through for the upstream to judge.
func normalizeReasoningEffort(effort string) string {
normalized := strings.ToLower(strings.TrimSpace(effort))
switch normalized {
case "none", "minimal", "low", "medium":
return "low"
case "xhigh", "max":
return "max"
default:
return normalized
}
}

// loadDefaultReasoningEffort resolves the effort injected for requests without
// reasoning, honoring OPENCODE_GO_DEFAULT_REASONING_EFFORT. "none" and "off"
// disable injection for operators whose upstream models reject the parameter.
func loadDefaultReasoningEffort() string {
override := strings.TrimSpace(os.Getenv(defaultReasoningEffortEnvVar))
if override == "" {
return defaultReasoningEffort
}
if strings.EqualFold(override, "none") || strings.EqualFold(override, "off") {
return ""
}
return normalizeReasoningEffort(override)
}
Loading