diff --git a/.env.template b/.env.template index 4d7996528..c18de7f06 100644 --- a/.env.template +++ b/.env.template @@ -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=... diff --git a/docs/providers/opencode-go.mdx b/docs/providers/opencode-go.mdx index d5a461dc2..a94e6ebb0 100644 --- a/docs/providers/opencode-go.mdx +++ b/docs/providers/opencode-go.mdx @@ -68,6 +68,45 @@ OPENCODE_GO_MESSAGES_MODELS=qwen3.7-max once the upstream model list distinguishes endpoints. +## 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`). diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 585762f15..5915dc0a8 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -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 diff --git a/internal/providers/opencodego/opencodego.go b/internal/providers/opencodego/opencodego.go index 6fb8db78b..f44c79bd6 100644 --- a/internal/providers/opencodego/opencodego.go +++ b/internal/providers/opencodego/opencodego.go @@ -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) @@ -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{ @@ -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{} { diff --git a/internal/providers/opencodego/reasoning.go b/internal/providers/opencodego/reasoning.go new file mode 100644 index 000000000..0ebdf0c9d --- /dev/null +++ b/internal/providers/opencodego/reasoning.go @@ -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)) + } + // 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) +} diff --git a/internal/providers/opencodego/reasoning_test.go b/internal/providers/opencodego/reasoning_test.go new file mode 100644 index 000000000..b6ea0da6f --- /dev/null +++ b/internal/providers/opencodego/reasoning_test.go @@ -0,0 +1,308 @@ +package opencodego + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" +) + +// captureBody serves a minimal chat completion and records the outgoing body. +func captureBody(t *testing.T, body *map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + return + } + if err := json.Unmarshal(raw, body); err != nil { + t.Errorf("unmarshal request body: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-opencode", + "created":1677652288, + "model":"ox-alpha-free", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) +} + +func TestChatCompletion_InjectsDefaultReasoningEffortWhenAbsent(t *testing.T) { + var got map[string]any + server := captureBody(t, &got) + defer server.Close() + + _, err := newTestProvider(server.URL, server.Client()).ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "ox-alpha-free", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if got["reasoning_effort"] != "low" { + t.Fatalf("reasoning_effort = %v, want low", got["reasoning_effort"]) + } + if _, ok := got["reasoning"]; ok { + t.Fatal("nested reasoning object should not be forwarded") + } +} + +func TestChatCompletion_MapsExplicitReasoningEffort(t *testing.T) { + tests := []struct { + name string + effort string + want string + }{ + {name: "low passes through", effort: "low", want: "low"}, + {name: "medium downgrades", effort: "medium", want: "low"}, + {name: "none becomes low", effort: "none", want: "low"}, + {name: "high passes through", effort: "high", want: "high"}, + {name: "xhigh maps to max", effort: "xhigh", want: "max"}, + {name: "max passes through", effort: "MAX", want: "max"}, + {name: "unknown level passes through", effort: "turbo", want: "turbo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got map[string]any + server := captureBody(t, &got) + defer server.Close() + + _, err := newTestProvider(server.URL, server.Client()).ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "ox-alpha-free", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Reasoning: &core.Reasoning{Effort: tt.effort}, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if got["reasoning_effort"] != tt.want { + t.Fatalf("reasoning_effort = %v, want %v", got["reasoning_effort"], tt.want) + } + }) + } +} + +func TestChatCompletion_KeepsClientSuppliedFlatReasoningEffort(t *testing.T) { + var got map[string]any + server := captureBody(t, &got) + defer server.Close() + + extra, err := core.MergeUnknownJSONFields(core.UnknownJSONFields{}, map[string]json.RawMessage{ + "reasoning_effort": json.RawMessage(`"max"`), + }) + if err != nil { + t.Fatalf("MergeUnknownJSONFields() error = %v", err) + } + + if _, err := newTestProvider(server.URL, server.Client()).ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "ox-alpha-free", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: extra, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if got["reasoning_effort"] != "max" { + t.Fatalf("reasoning_effort = %v, want max (client value preserved)", got["reasoning_effort"]) + } +} + +// TestChatCompletion_NestedReasoningWinsOverFlatField pins the precedence a +// self-contradicting request gets: reasoning.effort is GoModel's canonical +// field, so it wins over a flat reasoning_effort sent alongside it, matching +// every other provider built on AdaptReasoningEffortRequest. The flat field is +// only authoritative when no canonical reasoning is present, where it stops the +// default from being injected over it. +func TestChatCompletion_NestedReasoningWinsOverFlatField(t *testing.T) { + var got map[string]any + server := captureBody(t, &got) + defer server.Close() + + extra, err := core.MergeUnknownJSONFields(core.UnknownJSONFields{}, map[string]json.RawMessage{ + "reasoning_effort": json.RawMessage(`"max"`), + }) + if err != nil { + t.Fatalf("MergeUnknownJSONFields() error = %v", err) + } + + if _, err := newTestProvider(server.URL, server.Client()).ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "ox-alpha-free", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Reasoning: &core.Reasoning{Effort: "medium"}, + ExtraFields: extra, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if got["reasoning_effort"] != "low" { + t.Fatalf("reasoning_effort = %v, want low (canonical reasoning.effort wins)", got["reasoning_effort"]) + } +} + +func TestChatCompletion_DefaultReasoningEffortEnvOverride(t *testing.T) { + t.Setenv(defaultReasoningEffortEnvVar, "high") + + var got map[string]any + server := captureBody(t, &got) + defer server.Close() + + if _, err := newTestProvider(server.URL, server.Client()).ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "ox-alpha-free", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if got["reasoning_effort"] != "high" { + t.Fatalf("reasoning_effort = %v, want high", got["reasoning_effort"]) + } +} + +func TestChatCompletion_DefaultReasoningEffortDisabled(t *testing.T) { + for _, value := range []string{"none", "OFF"} { + t.Run(value, func(t *testing.T) { + t.Setenv(defaultReasoningEffortEnvVar, value) + + var got map[string]any + server := captureBody(t, &got) + defer server.Close() + + if _, err := newTestProvider(server.URL, server.Client()).ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "ox-alpha-free", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if _, ok := got["reasoning_effort"]; ok { + t.Fatalf("reasoning_effort = %v, want absent when injection is disabled", got["reasoning_effort"]) + } + }) + } +} + +func TestStreamChatCompletion_InjectsDefaultReasoningEffort(t *testing.T) { + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + return + } + if err := json.Unmarshal(raw, &got); err != nil { + t.Errorf("unmarshal request body: %v", err) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + body, err := newTestProvider(server.URL, server.Client()).StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "ox-alpha-free", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Stream: true, + }) + if err != nil { + t.Fatalf("StreamChatCompletion() error = %v", err) + } + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + + if got["reasoning_effort"] != "low" { + t.Fatalf("reasoning_effort = %v, want low", got["reasoning_effort"]) + } +} + +// TestResponses_InjectsDefaultReasoningEffort covers the /v1/responses path: +// it is translated to a chat completion by ResponsesViaChat, so the adaptation +// must reach the upstream body there too. +func TestResponses_InjectsDefaultReasoningEffort(t *testing.T) { + var got map[string]any + server := captureBody(t, &got) + defer server.Close() + + if _, err := newTestProvider(server.URL, server.Client()).Responses(context.Background(), &core.ResponsesRequest{ + Model: "ox-alpha-free", + Input: "hi", + }); err != nil { + t.Fatalf("Responses() error = %v", err) + } + if got["reasoning_effort"] != "low" { + t.Fatalf("reasoning_effort = %v, want low", got["reasoning_effort"]) + } +} + +// TestChatCompletion_MessagesModelUnaffected pins that the injection lives on +// the /chat/completions path only: the Anthropic-native dialect has no +// reasoning_effort field. +func TestChatCompletion_MessagesModelUnaffected(t *testing.T) { + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + return + } + if err := json.Unmarshal(raw, &got); err != nil { + t.Errorf("unmarshal request body: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"msg_opencode", + "model":"qwen3.7-max", + "content":[{"type":"text","text":"hello"}], + "stop_reason":"end_turn", + "usage":{"input_tokens":5,"output_tokens":2} + }`)) + })) + defer server.Close() + + if _, err := newTestProvider(server.URL, server.Client()).ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "qwen3.7-max", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if _, ok := got["reasoning_effort"]; ok { + t.Fatalf("reasoning_effort = %v, want absent on the /messages dialect", got["reasoning_effort"]) + } +} + +func TestAdaptChatRequest_NilRequest(t *testing.T) { + req, err := adaptChatRequest(defaultReasoningEffort)(nil) + if err != nil { + t.Fatalf("adaptChatRequest() error = %v", err) + } + if req != nil { + t.Fatalf("req = %#v, want nil", req) + } +} + +func TestLoadDefaultReasoningEffort(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "unset uses default", env: "", want: "low"}, + {name: "override normalized", env: " XHIGH ", want: "max"}, + {name: "none disables", env: "none", want: ""}, + {name: "off disables", env: "off", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(defaultReasoningEffortEnvVar, tt.env) + if got := loadDefaultReasoningEffort(); got != tt.want { + t.Fatalf("loadDefaultReasoningEffort() = %q, want %q", got, tt.want) + } + }) + } +}