detached 11ffb00

fix(provider): honor explicit reasoning mappings

commit
11ffb00360ec93fef89853f50a594e3a6aadaa99
author
brice · · 2026-08-10 13:56 UTC
committer
brice · · 2026-08-11 07:21 UTC
parent
d451e98
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +73 -17
M README.md +1 -1
@@ -533,7 +533,7 @@ Place a `models.json` in `$ZUT_HOME` (`$XDG_STATE_HOME/zut/` when set, otherwise
533533
534534 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
535535
536-`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
536+`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, use an identity mapping such as `"max": "max"` to enable a level beyond the protocol default, or map it to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
537537
538538 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.
539539
@@ -533,7 +533,7 @@ Place a `models.json` in `$ZUT_HOME` (`$XDG_STATE_HOME/zut/` when set, otherwise
533 533
534 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.534 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
535 535
536-`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.536+`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, use an identity mapping such as `"max": "max"` to enable a level beyond the protocol default, or map it to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
537 537
538 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.538 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.
539 539
M packages/provider/openai.go +5 -0
@@ -264,6 +264,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
264264 // don't clamp zut's "maximum" to "high" for those models.
265265 effort = OpenAICompatAnthropicEffort(reasoning)
266266 }
267+ if hasReasoningLevelOverride(m, req.Reasoning) {
268+ // Explicit model mappings describe the endpoint's actual effort
269+ // values and take precedence over conservative protocol defaults.
270+ effort = reasoning
271+ }
267272 if effort != "" {
268273 out.ReasoningEffort = effort
269274 }
@@ -264,6 +264,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
264 // don't clamp zut's "maximum" to "high" for those models.264 // don't clamp zut's "maximum" to "high" for those models.
265 effort = OpenAICompatAnthropicEffort(reasoning)265 effort = OpenAICompatAnthropicEffort(reasoning)
266 }266 }
267+ if hasReasoningLevelOverride(m, req.Reasoning) {
268+ // Explicit model mappings describe the endpoint's actual effort
269+ // values and take precedence over conservative protocol defaults.
270+ effort = reasoning
271+ }
267 if effort != "" {272 if effort != "" {
268 out.ReasoningEffort = effort273 out.ReasoningEffort = effort
269 }274 }
M packages/provider/openai_codex.go +5 -1
@@ -206,7 +206,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
206206 body.ServiceTier = fastModeServiceTier
207207 }
208208 if m.Reasoning {
209- if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {
209+ effort := OpenAICodexReasoningEffort(reasoning, req.Model)
210+ if hasReasoningLevelOverride(m, req.Reasoning) {
211+ effort = reasoning
212+ }
213+ if effort != "" {
210214 body.Reasoning = &codexReasoningConfig{Effort: effort}
211215 }
212216 }
@@ -206,7 +206,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
206 body.ServiceTier = fastModeServiceTier206 body.ServiceTier = fastModeServiceTier
207 }207 }
208 if m.Reasoning {208 if m.Reasoning {
209- if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {209+ effort := OpenAICodexReasoningEffort(reasoning, req.Model)
210+ if hasReasoningLevelOverride(m, req.Reasoning) {
211+ effort = reasoning
212+ }
213+ if effort != "" {
210 body.Reasoning = &codexReasoningConfig{Effort: effort}214 body.Reasoning = &codexReasoningConfig{Effort: effort}
211 }215 }
212 }216 }
M packages/provider/openai_codex_test.go +21 -0
@@ -126,6 +126,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
126126 }
127127 }
128128
129+func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
130+ SetLiveModels([]Model{{
131+ Provider: "custom-responses",
132+ ID: "reasoning-model",
133+ API: APIResponses,
134+ Reasoning: true,
135+ ReasoningLevelMap: map[string]string{"max": "max"},
136+ }})
137+ t.Cleanup(func() { SetLiveModels(nil) })
138+
139+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
140+ c := named.inner.(*codexClient)
141+ wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
146+ t.Fatalf("reasoning = %+v", wire.Reasoning)
147+ }
148+}
149+
129150 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
130151 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
131152 c := named.inner.(*codexClient)
@@ -126,6 +126,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
126 }126 }
127 }127 }
128 128
129+func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
130+ SetLiveModels([]Model{{
131+ Provider: "custom-responses",
132+ ID: "reasoning-model",
133+ API: APIResponses,
134+ Reasoning: true,
135+ ReasoningLevelMap: map[string]string{"max": "max"},
136+ }})
137+ t.Cleanup(func() { SetLiveModels(nil) })
138+
139+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
140+ c := named.inner.(*codexClient)
141+ wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
146+ t.Fatalf("reasoning = %+v", wire.Reasoning)
147+ }
148+}
149+
129 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {150 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
130 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)151 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
131 c := named.inner.(*codexClient)152 c := named.inner.(*codexClient)
M packages/provider/reasoning.go +7 -5
@@ -5,8 +5,8 @@ import "strings"
55 var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
66
77 // AvailableReasoningLevels returns the distinct reasoning levels supported by
8-// a model. Protocol defaults are adjusted by optional per-model overrides. The
9-// empty string represents off.
8+// a model. Optional per-model overrides can remove, remap, or extend protocol
9+// defaults. The empty string represents off.
1010 func AvailableReasoningLevels(model Model) []string {
1111 defaults := defaultReasoningLevels(model)
1212 if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {
@@ -24,9 +24,6 @@ func AvailableReasoningLevels(model Model) []string {
2424 if mapped, overridden := model.ReasoningLevelMap[level]; overridden {
2525 effective = NormalizeReasoning(mapped)
2626 }
27- if effective != "" && !containsReasoningLevel(defaults, effective) {
28- effective = nearestReasoningLevel(defaults, effective)
29- }
3027 if reasoningLevelRank(effective) > 0 {
3128 available[effective] = true
3229 }
@@ -90,6 +87,11 @@ func containsReasoningLevel(levels []string, target string) bool {
9087 return false
9188 }
9289
90+func hasReasoningLevelOverride(model Model, level string) bool {
91+ _, ok := model.ReasoningLevelMap[NormalizeReasoning(level)]
92+ return ok
93+}
94+
9395 // ClampReasoningForModel maps a configured level to the nearest level exposed
9496 // for the active model. Ties prefer the higher level.
9597 func ClampReasoningForModel(model Model, level string) string {
@@ -5,8 +5,8 @@ import "strings"
5 var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}5 var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
6 6
7 // AvailableReasoningLevels returns the distinct reasoning levels supported by7 // AvailableReasoningLevels returns the distinct reasoning levels supported by
8-// a model. Protocol defaults are adjusted by optional per-model overrides. The8+// a model. Optional per-model overrides can remove, remap, or extend protocol
9-// empty string represents off.9+// defaults. The empty string represents off.
10 func AvailableReasoningLevels(model Model) []string {10 func AvailableReasoningLevels(model Model) []string {
11 defaults := defaultReasoningLevels(model)11 defaults := defaultReasoningLevels(model)
12 if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {12 if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {
@@ -24,9 +24,6 @@ func AvailableReasoningLevels(model Model) []string {
24 if mapped, overridden := model.ReasoningLevelMap[level]; overridden {24 if mapped, overridden := model.ReasoningLevelMap[level]; overridden {
25 effective = NormalizeReasoning(mapped)25 effective = NormalizeReasoning(mapped)
26 }26 }
27- if effective != "" && !containsReasoningLevel(defaults, effective) {
28- effective = nearestReasoningLevel(defaults, effective)
29- }
30 if reasoningLevelRank(effective) > 0 {27 if reasoningLevelRank(effective) > 0 {
31 available[effective] = true28 available[effective] = true
32 }29 }
@@ -90,6 +87,11 @@ func containsReasoningLevel(levels []string, target string) bool {
90 return false87 return false
91 }88 }
92 89
90+func hasReasoningLevelOverride(model Model, level string) bool {
91+ _, ok := model.ReasoningLevelMap[NormalizeReasoning(level)]
92+ return ok
93+}
94+
93 // ClampReasoningForModel maps a configured level to the nearest level exposed95 // ClampReasoningForModel maps a configured level to the nearest level exposed
94 // for the active model. Ties prefer the higher level.96 // for the active model. Ties prefer the higher level.
95 func ClampReasoningForModel(model Model, level string) string {97 func ClampReasoningForModel(model Model, level string) string {
M packages/provider/reasoning_test.go +34 -10
@@ -29,7 +29,17 @@ func TestAvailableReasoningLevels(t *testing.T) {
2929 Provider: "anthropic", Reasoning: true,
3030 ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},
3131 },
32- want: []string{"", "low", "medium", "xhigh"},
32+ want: []string{"", "low", "medium", "xhigh", "max"},
33+ },
34+ {
35+ name: "explicit levels extend generic defaults",
36+ model: Model{
37+ Provider: "custom", Reasoning: true,
38+ ReasoningLevelMap: map[string]string{
39+ "minimum": "", "low": "", "medium": "", "high": "high", "xhigh": "xhigh", "max": "max",
40+ },
41+ },
42+ want: []string{"", "high", "xhigh", "max"},
3343 },
3444 }
3545 for _, tt := range tests {
@@ -59,6 +69,12 @@ func TestClampReasoningForModel(t *testing.T) {
5969 level: "minimum",
6070 want: "high",
6171 },
72+ {
73+ name: "explicit max extends generic defaults",
74+ model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"max": "max"}},
75+ level: "max",
76+ want: "max",
77+ },
6278 }
6379 for _, tt := range tests {
6480 t.Run(tt.name, func(t *testing.T) {
@@ -71,20 +87,28 @@ func TestClampReasoningForModel(t *testing.T) {
7187
7288 func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {
7389 SetLiveModels([]Model{{
74- Provider: "openai",
90+ Provider: "custom",
7591 ID: "mapped-reasoning-model",
7692 Reasoning: true,
77- ReasoningLevelMap: map[string]string{"high": "low"},
93+ ReasoningLevelMap: map[string]string{"high": "low", "max": "max"},
7894 }})
7995 t.Cleanup(func() { SetLiveModels(nil) })
8096
81- client := NewOpenAI("test", "").(*openaiClient)
82- request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"})
83- if err != nil {
84- t.Fatal(err)
85- }
86- if request.ReasoningEffort != "low" {
87- t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort)
97+ client := NewOpenAICompat("custom", "test", "", "").(*openaiClient)
98+ for _, tt := range []struct {
99+ requested string
100+ want string
101+ }{
102+ {requested: "high", want: "low"},
103+ {requested: "max", want: "max"},
104+ } {
105+ request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: tt.requested})
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ if request.ReasoningEffort != tt.want {
110+ t.Errorf("reasoning effort for %q = %q, want %q", tt.requested, request.ReasoningEffort, tt.want)
111+ }
88112 }
89113 }
90114
@@ -29,7 +29,17 @@ func TestAvailableReasoningLevels(t *testing.T) {
29 Provider: "anthropic", Reasoning: true,29 Provider: "anthropic", Reasoning: true,
30 ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},30 ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},
31 },31 },
32- want: []string{"", "low", "medium", "xhigh"},32+ want: []string{"", "low", "medium", "xhigh", "max"},
33+ },
34+ {
35+ name: "explicit levels extend generic defaults",
36+ model: Model{
37+ Provider: "custom", Reasoning: true,
38+ ReasoningLevelMap: map[string]string{
39+ "minimum": "", "low": "", "medium": "", "high": "high", "xhigh": "xhigh", "max": "max",
40+ },
41+ },
42+ want: []string{"", "high", "xhigh", "max"},
33 },43 },
34 }44 }
35 for _, tt := range tests {45 for _, tt := range tests {
@@ -59,6 +69,12 @@ func TestClampReasoningForModel(t *testing.T) {
59 level: "minimum",69 level: "minimum",
60 want: "high",70 want: "high",
61 },71 },
72+ {
73+ name: "explicit max extends generic defaults",
74+ model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"max": "max"}},
75+ level: "max",
76+ want: "max",
77+ },
62 }78 }
63 for _, tt := range tests {79 for _, tt := range tests {
64 t.Run(tt.name, func(t *testing.T) {80 t.Run(tt.name, func(t *testing.T) {
@@ -71,20 +87,28 @@ func TestClampReasoningForModel(t *testing.T) {
71 87
72 func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {88 func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {
73 SetLiveModels([]Model{{89 SetLiveModels([]Model{{
74- Provider: "openai",90+ Provider: "custom",
75 ID: "mapped-reasoning-model",91 ID: "mapped-reasoning-model",
76 Reasoning: true,92 Reasoning: true,
77- ReasoningLevelMap: map[string]string{"high": "low"},93+ ReasoningLevelMap: map[string]string{"high": "low", "max": "max"},
78 }})94 }})
79 t.Cleanup(func() { SetLiveModels(nil) })95 t.Cleanup(func() { SetLiveModels(nil) })
80 96
81- client := NewOpenAI("test", "").(*openaiClient)97+ client := NewOpenAICompat("custom", "test", "", "").(*openaiClient)
82- request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"})98+ for _, tt := range []struct {
83- if err != nil {99+ requested string
84- t.Fatal(err)100+ want string
85- }101+ }{
86- if request.ReasoningEffort != "low" {102+ {requested: "high", want: "low"},
87- t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort)103+ {requested: "max", want: "max"},
104+ } {
105+ request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: tt.requested})
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ if request.ReasoningEffort != tt.want {
110+ t.Errorf("reasoning effort for %q = %q, want %q", tt.requested, request.ReasoningEffort, tt.want)
111+ }
88 }112 }
89 }113 }
90 114

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