detached 124d679

feat(provider): adaptive thinking + xhigh effort for Opus 4.7/4.8

Opus 4.7+ only support adaptive thinking: explicit thinking budgets
(thinking:{type:enabled,budget_tokens:N}) and non-default sampling
params return 400. The Anthropic client now sends thinking:{type:
adaptive} plus output_config.effort and omits temperature for these
models, while older models keep the budget-based path. Adaptive models
are detected via a new Model.AdaptiveThinking flag with an id-substring
fallback so the same family reached through an Anthropic-Messages proxy
is handled too.

For adaptive Anthropic models served over the OpenAI-compatible chat-
completions wire (openrouter, opencode, ...), reasoning_effort now maps
maximum -> xhigh instead of clamping to high, preserving the model's
full reasoning ceiling. Adds AnthropicAdaptiveEffort and
OpenAICompatAnthropicEffort with tests.
commit
124d679982dab4fa8e8aa8f9ecd3c708e31c5932
author
patriceckhart · · 2026-05-29 12:21 UTC
parent
872a312
signature
unsigned
6 files · +235 -37
M packages/provider/anthropic.go +73 -26
@@ -162,19 +162,49 @@ type anthTool struct {
162162 }
163163
164164 type anthThinking struct {
165- Type string `json:"type"` // "enabled"
166- BudgetTokens int `json:"budget_tokens"`
165+ // Type is "enabled" for budget-based thinking (Opus 4.6 and
166+ // earlier) or "adaptive" for adaptive-thinking models (Opus 4.7+).
167+ Type string `json:"type"`
168+ // BudgetTokens is only sent for Type=="enabled". Adaptive models
169+ // reject it (400), so it is omitted for them.
170+ BudgetTokens int `json:"budget_tokens,omitempty"`
171+}
172+
173+// anthOutputConfig carries the effort knob used by adaptive-thinking
174+// models to control reasoning depth ("low"|"medium"|"high"|"xhigh").
175+type anthOutputConfig struct {
176+ Effort string `json:"effort,omitempty"`
167177 }
168178
169179 type anthRequest struct {
170- Model string `json:"model"`
171- MaxTokens int `json:"max_tokens"`
172- System []anthSystemBlock `json:"system,omitempty"`
173- Messages []anthMessage `json:"messages"`
174- Tools []anthTool `json:"tools,omitempty"`
175- Temperature *float32 `json:"temperature,omitempty"`
176- Thinking *anthThinking `json:"thinking,omitempty"`
177- Stream bool `json:"stream"`
180+ Model string `json:"model"`
181+ MaxTokens int `json:"max_tokens"`
182+ System []anthSystemBlock `json:"system,omitempty"`
183+ Messages []anthMessage `json:"messages"`
184+ Tools []anthTool `json:"tools,omitempty"`
185+ Temperature *float32 `json:"temperature,omitempty"`
186+ Thinking *anthThinking `json:"thinking,omitempty"`
187+ OutputConfig *anthOutputConfig `json:"output_config,omitempty"`
188+ Stream bool `json:"stream"`
189+}
190+
191+// usesAdaptiveThinking reports whether a model only supports the
192+// adaptive thinking mode (Opus 4.7 and later). These models reject
193+// explicit thinking budgets and non-default sampling parameters. The
194+// catalog flag is authoritative; the id-substring fallback catches
195+// the same family when reached through an Anthropic-Messages-
196+// compatible proxy whose catalog row predates the flag.
197+func usesAdaptiveThinking(m Model) bool {
198+ if m.AdaptiveThinking {
199+ return true
200+ }
201+ id := strings.ToLower(m.ID)
202+ for _, marker := range []string{"opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8"} {
203+ if strings.Contains(id, marker) {
204+ return true
205+ }
206+ }
207+ return false
178208 }
179209
180210 // ---- request building ----
@@ -200,11 +230,18 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
200230 maxTok = m.MaxOutput
201231 }
202232
233+ adaptive := usesAdaptiveThinking(m)
234+
203235 out := &anthRequest{
204- Model: req.Model,
205- MaxTokens: maxTok,
206- Temperature: req.Temperature,
207- Stream: true,
236+ Model: req.Model,
237+ MaxTokens: maxTok,
238+ Stream: true,
239+ }
240+ // Adaptive-thinking models reject non-default sampling params
241+ // (temperature/top_p/top_k -> 400). Only forward temperature for
242+ // models that accept it.
243+ if !adaptive {
244+ out.Temperature = req.Temperature
208245 }
209246
210247 // System prompt assembly differs between api-key and OAuth modes.
@@ -247,19 +284,29 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
247284 }
248285
249286 if req.Reasoning != "" && m.Reasoning {
250- budget := anthropicReasoningBudget(req.Reasoning)
251- if budget > 0 {
252- // Reasoning requires max_tokens > budget. Keep at least a small
253- // answer budget while respecting the model's advertised output cap.
254- const minAnswerTokens = 1024
255- if m.MaxOutput > minAnswerTokens && budget >= m.MaxOutput {
256- budget = m.MaxOutput - minAnswerTokens
287+ if adaptive {
288+ // Adaptive-thinking models (Opus 4.7+): the model decides when
289+ // and how much to think; depth is steered by output_config.effort.
290+ // Explicit budgets are rejected with a 400, so none is sent.
291+ out.Thinking = &anthThinking{Type: "adaptive"}
292+ if effort := AnthropicAdaptiveEffort(req.Reasoning); effort != "" {
293+ out.OutputConfig = &anthOutputConfig{Effort: effort}
257294 }
258- out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
259- if out.MaxTokens <= budget {
260- out.MaxTokens = budget + minAnswerTokens
261- if m.MaxOutput > 0 && out.MaxTokens > m.MaxOutput {
262- out.MaxTokens = m.MaxOutput
295+ } else {
296+ budget := anthropicReasoningBudget(req.Reasoning)
297+ if budget > 0 {
298+ // Reasoning requires max_tokens > budget. Keep at least a small
299+ // answer budget while respecting the model's advertised output cap.
300+ const minAnswerTokens = 1024
301+ if m.MaxOutput > minAnswerTokens && budget >= m.MaxOutput {
302+ budget = m.MaxOutput - minAnswerTokens
303+ }
304+ out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
305+ if out.MaxTokens <= budget {
306+ out.MaxTokens = budget + minAnswerTokens
307+ if m.MaxOutput > 0 && out.MaxTokens > m.MaxOutput {
308+ out.MaxTokens = m.MaxOutput
309+ }
263310 }
264311 }
265312 }
@@ -162,19 +162,49 @@ type anthTool struct {
162 }162 }
163 163
164 type anthThinking struct {164 type anthThinking struct {
165- Type string `json:"type"` // "enabled"165+ // Type is "enabled" for budget-based thinking (Opus 4.6 and
166- BudgetTokens int `json:"budget_tokens"`166+ // earlier) or "adaptive" for adaptive-thinking models (Opus 4.7+).
167+ Type string `json:"type"`
168+ // BudgetTokens is only sent for Type=="enabled". Adaptive models
169+ // reject it (400), so it is omitted for them.
170+ BudgetTokens int `json:"budget_tokens,omitempty"`
171+}
172+
173+// anthOutputConfig carries the effort knob used by adaptive-thinking
174+// models to control reasoning depth ("low"|"medium"|"high"|"xhigh").
175+type anthOutputConfig struct {
176+ Effort string `json:"effort,omitempty"`
167 }177 }
168 178
169 type anthRequest struct {179 type anthRequest struct {
170- Model string `json:"model"`180+ Model string `json:"model"`
171- MaxTokens int `json:"max_tokens"`181+ MaxTokens int `json:"max_tokens"`
172- System []anthSystemBlock `json:"system,omitempty"`182+ System []anthSystemBlock `json:"system,omitempty"`
173- Messages []anthMessage `json:"messages"`183+ Messages []anthMessage `json:"messages"`
174- Tools []anthTool `json:"tools,omitempty"`184+ Tools []anthTool `json:"tools,omitempty"`
175- Temperature *float32 `json:"temperature,omitempty"`185+ Temperature *float32 `json:"temperature,omitempty"`
176- Thinking *anthThinking `json:"thinking,omitempty"`186+ Thinking *anthThinking `json:"thinking,omitempty"`
177- Stream bool `json:"stream"`187+ OutputConfig *anthOutputConfig `json:"output_config,omitempty"`
188+ Stream bool `json:"stream"`
189+}
190+
191+// usesAdaptiveThinking reports whether a model only supports the
192+// adaptive thinking mode (Opus 4.7 and later). These models reject
193+// explicit thinking budgets and non-default sampling parameters. The
194+// catalog flag is authoritative; the id-substring fallback catches
195+// the same family when reached through an Anthropic-Messages-
196+// compatible proxy whose catalog row predates the flag.
197+func usesAdaptiveThinking(m Model) bool {
198+ if m.AdaptiveThinking {
199+ return true
200+ }
201+ id := strings.ToLower(m.ID)
202+ for _, marker := range []string{"opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8"} {
203+ if strings.Contains(id, marker) {
204+ return true
205+ }
206+ }
207+ return false
178 }208 }
179 209
180 // ---- request building ----210 // ---- request building ----
@@ -200,11 +230,18 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
200 maxTok = m.MaxOutput230 maxTok = m.MaxOutput
201 }231 }
202 232
233+ adaptive := usesAdaptiveThinking(m)
234+
203 out := &anthRequest{235 out := &anthRequest{
204- Model: req.Model,236+ Model: req.Model,
205- MaxTokens: maxTok,237+ MaxTokens: maxTok,
206- Temperature: req.Temperature,238+ Stream: true,
207- Stream: true,239+ }
240+ // Adaptive-thinking models reject non-default sampling params
241+ // (temperature/top_p/top_k -> 400). Only forward temperature for
242+ // models that accept it.
243+ if !adaptive {
244+ out.Temperature = req.Temperature
208 }245 }
209 246
210 // System prompt assembly differs between api-key and OAuth modes.247 // System prompt assembly differs between api-key and OAuth modes.
@@ -247,19 +284,29 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
247 }284 }
248 285
249 if req.Reasoning != "" && m.Reasoning {286 if req.Reasoning != "" && m.Reasoning {
250- budget := anthropicReasoningBudget(req.Reasoning)287+ if adaptive {
251- if budget > 0 {288+ // Adaptive-thinking models (Opus 4.7+): the model decides when
252- // Reasoning requires max_tokens > budget. Keep at least a small289+ // and how much to think; depth is steered by output_config.effort.
253- // answer budget while respecting the model's advertised output cap.290+ // Explicit budgets are rejected with a 400, so none is sent.
254- const minAnswerTokens = 1024291+ out.Thinking = &anthThinking{Type: "adaptive"}
255- if m.MaxOutput > minAnswerTokens && budget >= m.MaxOutput {292+ if effort := AnthropicAdaptiveEffort(req.Reasoning); effort != "" {
256- budget = m.MaxOutput - minAnswerTokens293+ out.OutputConfig = &anthOutputConfig{Effort: effort}
257 }294 }
258- out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}295+ } else {
259- if out.MaxTokens <= budget {296+ budget := anthropicReasoningBudget(req.Reasoning)
260- out.MaxTokens = budget + minAnswerTokens297+ if budget > 0 {
261- if m.MaxOutput > 0 && out.MaxTokens > m.MaxOutput {298+ // Reasoning requires max_tokens > budget. Keep at least a small
262- out.MaxTokens = m.MaxOutput299+ // answer budget while respecting the model's advertised output cap.
300+ const minAnswerTokens = 1024
301+ if m.MaxOutput > minAnswerTokens && budget >= m.MaxOutput {
302+ budget = m.MaxOutput - minAnswerTokens
303+ }
304+ out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
305+ if out.MaxTokens <= budget {
306+ out.MaxTokens = budget + minAnswerTokens
307+ if m.MaxOutput > 0 && out.MaxTokens > m.MaxOutput {
308+ out.MaxTokens = m.MaxOutput
309+ }
263 }310 }
264 }311 }
265 }312 }
M packages/provider/models.go +10 -2
@@ -14,6 +14,14 @@ type Model struct {
1414 MaxOutput int
1515 Reasoning bool // supports reasoning/thinking
1616
17+ // AdaptiveThinking marks Anthropic models that only support the
18+ // adaptive thinking mode (Opus 4.7+). These reject explicit
19+ // thinking budgets (thinking:{type:"enabled",budget_tokens:N} -> 400)
20+ // and also reject non-default sampling params (temperature/top_p/
21+ // top_k). The Anthropic client sends thinking:{type:"adaptive"} plus
22+ // output_config.effort and omits temperature for these models.
23+ AdaptiveThinking bool
24+
1725 // Prices are USD per 1M tokens.
1826 PriceInput float64
1927 PriceOutput float64
@@ -230,13 +238,13 @@ var Catalog = []Model{
230238 },
231239 {
232240 Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",
233- ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
241+ ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
234242 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
235243 Speculative: true,
236244 },
237245 {
238246 Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
239- ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
247+ ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
240248 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
241249 Speculative: true,
242250 },
@@ -14,6 +14,14 @@ type Model struct {
14 MaxOutput int14 MaxOutput int
15 Reasoning bool // supports reasoning/thinking15 Reasoning bool // supports reasoning/thinking
16 16
17+ // AdaptiveThinking marks Anthropic models that only support the
18+ // adaptive thinking mode (Opus 4.7+). These reject explicit
19+ // thinking budgets (thinking:{type:"enabled",budget_tokens:N} -> 400)
20+ // and also reject non-default sampling params (temperature/top_p/
21+ // top_k). The Anthropic client sends thinking:{type:"adaptive"} plus
22+ // output_config.effort and omits temperature for these models.
23+ AdaptiveThinking bool
24+
17 // Prices are USD per 1M tokens.25 // Prices are USD per 1M tokens.
18 PriceInput float6426 PriceInput float64
19 PriceOutput float6427 PriceOutput float64
@@ -230,13 +238,13 @@ var Catalog = []Model{
230 },238 },
231 {239 {
232 Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",240 Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",
233- ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,241+ ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
234 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,242 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
235 Speculative: true,243 Speculative: true,
236 },244 },
237 {245 {
238 Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",246 Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
239- ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,247+ ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
240 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,248 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
241 Speculative: true,249 Speculative: true,
242 },250 },
M packages/provider/openai.go +9 -1
@@ -185,7 +185,15 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
185185 }
186186 if m.Reasoning {
187187 out.MaxCompletionTok = &maxTok
188- if effort := OpenAIReasoningEffort(req.Reasoning); effort != "" {
188+ effort := OpenAIReasoningEffort(req.Reasoning)
189+ if usesAdaptiveThinking(m) {
190+ // Some gateways expose adaptive-thinking Anthropic models through
191+ // the OpenAI-compatible chat-completions wire. They accept the
192+ // same reasoning_effort knob, including the top "xhigh" tier;
193+ // don't clamp zot's "maximum" to "high" for those models.
194+ effort = OpenAICompatAnthropicEffort(req.Reasoning)
195+ }
196+ if effort != "" {
189197 out.ReasoningEffort = effort
190198 }
191199 } else {
@@ -185,7 +185,15 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
185 }185 }
186 if m.Reasoning {186 if m.Reasoning {
187 out.MaxCompletionTok = &maxTok187 out.MaxCompletionTok = &maxTok
188- if effort := OpenAIReasoningEffort(req.Reasoning); effort != "" {188+ effort := OpenAIReasoningEffort(req.Reasoning)
189+ if usesAdaptiveThinking(m) {
190+ // Some gateways expose adaptive-thinking Anthropic models through
191+ // the OpenAI-compatible chat-completions wire. They accept the
192+ // same reasoning_effort knob, including the top "xhigh" tier;
193+ // don't clamp zot's "maximum" to "high" for those models.
194+ effort = OpenAICompatAnthropicEffort(req.Reasoning)
195+ }
196+ if effort != "" {
189 out.ReasoningEffort = effort197 out.ReasoningEffort = effort
190 }198 }
191 } else {199 } else {
M packages/provider/provider_test.go +88 -0
@@ -78,6 +78,67 @@ func TestOpenAIErrorStatus(t *testing.T) {
7878 }
7979 }
8080
81+func TestAnthropicAdaptiveThinking(t *testing.T) {
82+ c := NewAnthropic("x", "").(*anthropicClient)
83+ temp := float32(0.7)
84+
85+ // Opus 4.8 -> adaptive thinking, effort set, no budget, no temperature.
86+ wire, err := c.buildRequest(Request{
87+ Model: "claude-opus-4-8",
88+ Reasoning: "high",
89+ Temperature: &temp,
90+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
91+ })
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if wire.Thinking == nil || wire.Thinking.Type != "adaptive" {
96+ t.Fatalf("want adaptive thinking, got %+v", wire.Thinking)
97+ }
98+ if wire.Thinking.BudgetTokens != 0 {
99+ t.Fatalf("adaptive must not send budget_tokens, got %d", wire.Thinking.BudgetTokens)
100+ }
101+ if wire.OutputConfig == nil || wire.OutputConfig.Effort != "high" {
102+ t.Fatalf("want effort=high, got %+v", wire.OutputConfig)
103+ }
104+ if wire.Temperature != nil {
105+ t.Fatalf("adaptive must drop temperature, got %v", *wire.Temperature)
106+ }
107+
108+ // maximum -> xhigh effort.
109+ wire, err = c.buildRequest(Request{
110+ Model: "claude-opus-4-8",
111+ Reasoning: "maximum",
112+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
113+ })
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ if wire.OutputConfig == nil || wire.OutputConfig.Effort != "xhigh" {
118+ t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig)
119+ }
120+
121+ // Opus 4.5 -> budget-based thinking, no output_config, temperature kept.
122+ wire, err = c.buildRequest(Request{
123+ Model: "claude-opus-4-5",
124+ Reasoning: "high",
125+ Temperature: &temp,
126+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
127+ })
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if wire.Thinking == nil || wire.Thinking.Type != "enabled" || wire.Thinking.BudgetTokens <= 0 {
132+ t.Fatalf("want budget thinking, got %+v", wire.Thinking)
133+ }
134+ if wire.OutputConfig != nil {
135+ t.Fatalf("budget models must not send output_config, got %+v", wire.OutputConfig)
136+ }
137+ if wire.Temperature == nil || *wire.Temperature != temp {
138+ t.Fatalf("budget model should keep temperature, got %v", wire.Temperature)
139+ }
140+}
141+
81142 func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) {
82143 c := NewAnthropic("x", "").(*anthropicClient)
83144 wire, err := c.buildRequest(Request{
@@ -155,6 +216,33 @@ func TestAnthropicStreamHappyPath(t *testing.T) {
155216 }
156217 }
157218
219+func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) {
220+ c := NewOpenRouter("token", "").(*openaiClient)
221+ wire, err := c.buildRequest(Request{
222+ Model: "anthropic/claude-opus-4.8",
223+ Reasoning: "maximum",
224+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
225+ })
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ if wire.ReasoningEffort != "xhigh" {
230+ t.Fatalf("want xhigh for adaptive Anthropic model over OpenAI-compatible wire, got %q", wire.ReasoningEffort)
231+ }
232+
233+ wire, err = c.buildRequest(Request{
234+ Model: "gpt-5.1",
235+ Reasoning: "maximum",
236+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
237+ })
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ if wire.ReasoningEffort != "high" {
242+ t.Fatalf("want generic OpenAI-compatible maximum clamped to high, got %q", wire.ReasoningEffort)
243+ }
244+}
245+
158246 func TestOpenAIBuildRequestSkipsReasoningOnlyAssistantMessages(t *testing.T) {
159247 c := NewKimi("token", "").(*openaiClient)
160248 wire, err := c.buildRequest(Request{
@@ -78,6 +78,67 @@ func TestOpenAIErrorStatus(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestAnthropicAdaptiveThinking(t *testing.T) {
82+ c := NewAnthropic("x", "").(*anthropicClient)
83+ temp := float32(0.7)
84+
85+ // Opus 4.8 -> adaptive thinking, effort set, no budget, no temperature.
86+ wire, err := c.buildRequest(Request{
87+ Model: "claude-opus-4-8",
88+ Reasoning: "high",
89+ Temperature: &temp,
90+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
91+ })
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if wire.Thinking == nil || wire.Thinking.Type != "adaptive" {
96+ t.Fatalf("want adaptive thinking, got %+v", wire.Thinking)
97+ }
98+ if wire.Thinking.BudgetTokens != 0 {
99+ t.Fatalf("adaptive must not send budget_tokens, got %d", wire.Thinking.BudgetTokens)
100+ }
101+ if wire.OutputConfig == nil || wire.OutputConfig.Effort != "high" {
102+ t.Fatalf("want effort=high, got %+v", wire.OutputConfig)
103+ }
104+ if wire.Temperature != nil {
105+ t.Fatalf("adaptive must drop temperature, got %v", *wire.Temperature)
106+ }
107+
108+ // maximum -> xhigh effort.
109+ wire, err = c.buildRequest(Request{
110+ Model: "claude-opus-4-8",
111+ Reasoning: "maximum",
112+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
113+ })
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ if wire.OutputConfig == nil || wire.OutputConfig.Effort != "xhigh" {
118+ t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig)
119+ }
120+
121+ // Opus 4.5 -> budget-based thinking, no output_config, temperature kept.
122+ wire, err = c.buildRequest(Request{
123+ Model: "claude-opus-4-5",
124+ Reasoning: "high",
125+ Temperature: &temp,
126+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
127+ })
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if wire.Thinking == nil || wire.Thinking.Type != "enabled" || wire.Thinking.BudgetTokens <= 0 {
132+ t.Fatalf("want budget thinking, got %+v", wire.Thinking)
133+ }
134+ if wire.OutputConfig != nil {
135+ t.Fatalf("budget models must not send output_config, got %+v", wire.OutputConfig)
136+ }
137+ if wire.Temperature == nil || *wire.Temperature != temp {
138+ t.Fatalf("budget model should keep temperature, got %v", wire.Temperature)
139+ }
140+}
141+
81 func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) {142 func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) {
82 c := NewAnthropic("x", "").(*anthropicClient)143 c := NewAnthropic("x", "").(*anthropicClient)
83 wire, err := c.buildRequest(Request{144 wire, err := c.buildRequest(Request{
@@ -155,6 +216,33 @@ func TestAnthropicStreamHappyPath(t *testing.T) {
155 }216 }
156 }217 }
157 218
219+func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) {
220+ c := NewOpenRouter("token", "").(*openaiClient)
221+ wire, err := c.buildRequest(Request{
222+ Model: "anthropic/claude-opus-4.8",
223+ Reasoning: "maximum",
224+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
225+ })
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ if wire.ReasoningEffort != "xhigh" {
230+ t.Fatalf("want xhigh for adaptive Anthropic model over OpenAI-compatible wire, got %q", wire.ReasoningEffort)
231+ }
232+
233+ wire, err = c.buildRequest(Request{
234+ Model: "gpt-5.1",
235+ Reasoning: "maximum",
236+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
237+ })
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ if wire.ReasoningEffort != "high" {
242+ t.Fatalf("want generic OpenAI-compatible maximum clamped to high, got %q", wire.ReasoningEffort)
243+ }
244+}
245+
158 func TestOpenAIBuildRequestSkipsReasoningOnlyAssistantMessages(t *testing.T) {246 func TestOpenAIBuildRequestSkipsReasoningOnlyAssistantMessages(t *testing.T) {
159 c := NewKimi("token", "").(*openaiClient)247 c := NewKimi("token", "").(*openaiClient)
160 wire, err := c.buildRequest(Request{248 wire, err := c.buildRequest(Request{
M packages/provider/reasoning.go +43 -0
@@ -42,6 +42,26 @@ func ReasoningBudget(level string) int {
4242 }
4343 }
4444
45+// AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the
46+// effort enum used by Anthropic's adaptive-thinking models (Opus 4.7+).
47+// These models reject explicit thinking budgets; thinking depth is
48+// controlled by output_config.effort instead. Returns "" when reasoning
49+// is disabled.
50+func AnthropicAdaptiveEffort(level string) string {
51+ switch NormalizeReasoning(level) {
52+ case "minimum", "low":
53+ return "low"
54+ case "medium":
55+ return "medium"
56+ case "high":
57+ return "high"
58+ case "maximum":
59+ return "xhigh"
60+ default:
61+ return ""
62+ }
63+}
64+
4565 // OpenAIReasoningEffort maps zot's six-level setting onto the effort enum
4666 // accepted by OpenAI-compatible chat-completions endpoints.
4767 func OpenAIReasoningEffort(level string) string {
@@ -59,6 +79,29 @@ func OpenAIReasoningEffort(level string) string {
5979 }
6080 }
6181
82+// OpenAICompatAnthropicEffort maps zot's user-facing thinking levels
83+// onto reasoning_effort values when an adaptive-thinking Anthropic
84+// model (Opus 4.7+) is served over the OpenAI-compatible chat-
85+// completions wire (openrouter, opencode, ...). Differs from
86+// OpenAIReasoningEffort only at the top: zot's "maximum" maps to
87+// "xhigh" instead of being clamped to "high", so the model's full
88+// adaptive-thinking ceiling is preserved when reachable through a
89+// gateway that accepts the effort knob.
90+func OpenAICompatAnthropicEffort(level string) string {
91+ switch NormalizeReasoning(level) {
92+ case "minimum", "low":
93+ return "low"
94+ case "medium":
95+ return "medium"
96+ case "high":
97+ return "high"
98+ case "maximum":
99+ return "xhigh"
100+ default:
101+ return ""
102+ }
103+}
104+
62105 // OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex
63106 // Responses backend enum. That backend rejects "minimal" and uses
64107 // "xhigh" for the highest tier on recent GPT-5.x models.
@@ -42,6 +42,26 @@ func ReasoningBudget(level string) int {
42 }42 }
43 }43 }
44 44
45+// AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the
46+// effort enum used by Anthropic's adaptive-thinking models (Opus 4.7+).
47+// These models reject explicit thinking budgets; thinking depth is
48+// controlled by output_config.effort instead. Returns "" when reasoning
49+// is disabled.
50+func AnthropicAdaptiveEffort(level string) string {
51+ switch NormalizeReasoning(level) {
52+ case "minimum", "low":
53+ return "low"
54+ case "medium":
55+ return "medium"
56+ case "high":
57+ return "high"
58+ case "maximum":
59+ return "xhigh"
60+ default:
61+ return ""
62+ }
63+}
64+
45 // OpenAIReasoningEffort maps zot's six-level setting onto the effort enum65 // OpenAIReasoningEffort maps zot's six-level setting onto the effort enum
46 // accepted by OpenAI-compatible chat-completions endpoints.66 // accepted by OpenAI-compatible chat-completions endpoints.
47 func OpenAIReasoningEffort(level string) string {67 func OpenAIReasoningEffort(level string) string {
@@ -59,6 +79,29 @@ func OpenAIReasoningEffort(level string) string {
59 }79 }
60 }80 }
61 81
82+// OpenAICompatAnthropicEffort maps zot's user-facing thinking levels
83+// onto reasoning_effort values when an adaptive-thinking Anthropic
84+// model (Opus 4.7+) is served over the OpenAI-compatible chat-
85+// completions wire (openrouter, opencode, ...). Differs from
86+// OpenAIReasoningEffort only at the top: zot's "maximum" maps to
87+// "xhigh" instead of being clamped to "high", so the model's full
88+// adaptive-thinking ceiling is preserved when reachable through a
89+// gateway that accepts the effort knob.
90+func OpenAICompatAnthropicEffort(level string) string {
91+ switch NormalizeReasoning(level) {
92+ case "minimum", "low":
93+ return "low"
94+ case "medium":
95+ return "medium"
96+ case "high":
97+ return "high"
98+ case "maximum":
99+ return "xhigh"
100+ default:
101+ return ""
102+ }
103+}
104+
62 // OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex105 // OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex
63 // Responses backend enum. That backend rejects "minimal" and uses106 // Responses backend enum. That backend rejects "minimal" and uses
64 // "xhigh" for the highest tier on recent GPT-5.x models.107 // "xhigh" for the highest tier on recent GPT-5.x models.
M packages/provider/reasoning_test.go +12 -8
@@ -6,18 +6,19 @@ func TestReasoningEffortMappings(t *testing.T) {
66 cases := []struct {
77 level string
88 openai string
9+ anthCompat string
910 codex string
1011 budget int
1112 normalized string
1213 }{
13- {"off", "", "", 0, ""},
14- {"minimum", "low", "low", 1024, "minimum"},
15- {"minimal", "low", "low", 1024, "minimum"},
16- {"low", "low", "low", 2048, "low"},
17- {"medium", "medium", "medium", 8192, "medium"},
18- {"high", "high", "high", 16384, "high"},
19- {"maximum", "high", "xhigh", 32768, "maximum"},
20- {"max", "high", "xhigh", 32768, "maximum"},
14+ {"off", "", "", "", 0, ""},
15+ {"minimum", "low", "low", "low", 1024, "minimum"},
16+ {"minimal", "low", "low", "low", 1024, "minimum"},
17+ {"low", "low", "low", "low", 2048, "low"},
18+ {"medium", "medium", "medium", "medium", 8192, "medium"},
19+ {"high", "high", "high", "high", 16384, "high"},
20+ {"maximum", "high", "xhigh", "xhigh", 32768, "maximum"},
21+ {"max", "high", "xhigh", "xhigh", 32768, "maximum"},
2122 }
2223 for _, tc := range cases {
2324 if got := NormalizeReasoning(tc.level); got != tc.normalized {
@@ -26,6 +27,9 @@ func TestReasoningEffortMappings(t *testing.T) {
2627 if got := OpenAIReasoningEffort(tc.level); got != tc.openai {
2728 t.Errorf("OpenAIReasoningEffort(%q)=%q want %q", tc.level, got, tc.openai)
2829 }
30+ if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat {
31+ t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat)
32+ }
2933 if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex {
3034 t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex)
3135 }
@@ -6,18 +6,19 @@ func TestReasoningEffortMappings(t *testing.T) {
6 cases := []struct {6 cases := []struct {
7 level string7 level string
8 openai string8 openai string
9+ anthCompat string
9 codex string10 codex string
10 budget int11 budget int
11 normalized string12 normalized string
12 }{13 }{
13- {"off", "", "", 0, ""},14+ {"off", "", "", "", 0, ""},
14- {"minimum", "low", "low", 1024, "minimum"},15+ {"minimum", "low", "low", "low", 1024, "minimum"},
15- {"minimal", "low", "low", 1024, "minimum"},16+ {"minimal", "low", "low", "low", 1024, "minimum"},
16- {"low", "low", "low", 2048, "low"},17+ {"low", "low", "low", "low", 2048, "low"},
17- {"medium", "medium", "medium", 8192, "medium"},18+ {"medium", "medium", "medium", "medium", 8192, "medium"},
18- {"high", "high", "high", 16384, "high"},19+ {"high", "high", "high", "high", 16384, "high"},
19- {"maximum", "high", "xhigh", 32768, "maximum"},20+ {"maximum", "high", "xhigh", "xhigh", 32768, "maximum"},
20- {"max", "high", "xhigh", 32768, "maximum"},21+ {"max", "high", "xhigh", "xhigh", 32768, "maximum"},
21 }22 }
22 for _, tc := range cases {23 for _, tc := range cases {
23 if got := NormalizeReasoning(tc.level); got != tc.normalized {24 if got := NormalizeReasoning(tc.level); got != tc.normalized {
@@ -26,6 +27,9 @@ func TestReasoningEffortMappings(t *testing.T) {
26 if got := OpenAIReasoningEffort(tc.level); got != tc.openai {27 if got := OpenAIReasoningEffort(tc.level); got != tc.openai {
27 t.Errorf("OpenAIReasoningEffort(%q)=%q want %q", tc.level, got, tc.openai)28 t.Errorf("OpenAIReasoningEffort(%q)=%q want %q", tc.level, got, tc.openai)
28 }29 }
30+ if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat {
31+ t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat)
32+ }
29 if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex {33 if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex {
30 t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex)34 t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex)
31 }35 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close