detached 1e4a8c4

Honor explicit reasoning level overrides

commit
1e4a8c46a147d7e7514aacc3aa6f738dc4fd872b
author
patriceckhart · · 2026-08-07 13:39 UTC
parent
f0f836b
signature
unsigned
refs
tag: v0.3.38
6 files · +73 -17
M README.md +1 -1
@@ -487,7 +487,7 @@ Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise
487487
488488 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
489489
490-`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.
490+`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.
491491
492492 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.
493493
@@ -487,7 +487,7 @@ Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise
487 487
488 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.488 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
489 489
490-`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.490+`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.
491 491
492 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.492 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.
493 493
M packages/provider/openai.go +5 -0
@@ -257,6 +257,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
257257 // don't clamp zot's "maximum" to "high" for those models.
258258 effort = OpenAICompatAnthropicEffort(reasoning)
259259 }
260+ if hasReasoningLevelOverride(m, req.Reasoning) {
261+ // Explicit model mappings describe the endpoint's actual effort
262+ // values and take precedence over conservative protocol defaults.
263+ effort = reasoning
264+ }
260265 if effort != "" {
261266 out.ReasoningEffort = effort
262267 }
@@ -257,6 +257,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
257 // don't clamp zot's "maximum" to "high" for those models.257 // don't clamp zot's "maximum" to "high" for those models.
258 effort = OpenAICompatAnthropicEffort(reasoning)258 effort = OpenAICompatAnthropicEffort(reasoning)
259 }259 }
260+ if hasReasoningLevelOverride(m, req.Reasoning) {
261+ // Explicit model mappings describe the endpoint's actual effort
262+ // values and take precedence over conservative protocol defaults.
263+ effort = reasoning
264+ }
260 if effort != "" {265 if effort != "" {
261 out.ReasoningEffort = effort266 out.ReasoningEffort = effort
262 }267 }
M packages/provider/openai_codex.go +5 -1
@@ -195,7 +195,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
195195 Include: []string{"reasoning.encrypted_content"},
196196 }
197197 if m.Reasoning {
198- if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {
198+ effort := OpenAICodexReasoningEffort(reasoning, req.Model)
199+ if hasReasoningLevelOverride(m, req.Reasoning) {
200+ effort = reasoning
201+ }
202+ if effort != "" {
199203 body.Reasoning = &codexReasoningConfig{Effort: effort}
200204 }
201205 }
@@ -195,7 +195,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
195 Include: []string{"reasoning.encrypted_content"},195 Include: []string{"reasoning.encrypted_content"},
196 }196 }
197 if m.Reasoning {197 if m.Reasoning {
198- if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {198+ effort := OpenAICodexReasoningEffort(reasoning, req.Model)
199+ if hasReasoningLevelOverride(m, req.Reasoning) {
200+ effort = reasoning
201+ }
202+ if effort != "" {
199 body.Reasoning = &codexReasoningConfig{Effort: effort}203 body.Reasoning = &codexReasoningConfig{Effort: effort}
200 }204 }
201 }205 }
M packages/provider/openai_codex_test.go +21 -0
@@ -125,6 +125,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
125125 }
126126 }
127127
128+func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
129+ SetLiveModels([]Model{{
130+ Provider: "custom-responses",
131+ ID: "reasoning-model",
132+ API: APIResponses,
133+ Reasoning: true,
134+ ReasoningLevelMap: map[string]string{"max": "max"},
135+ }})
136+ t.Cleanup(func() { SetLiveModels(nil) })
137+
138+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
139+ c := named.inner.(*codexClient)
140+ wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
145+ t.Fatalf("reasoning = %+v", wire.Reasoning)
146+ }
147+}
148+
128149 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
129150 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
130151 c := named.inner.(*codexClient)
@@ -125,6 +125,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
125 }125 }
126 }126 }
127 127
128+func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
129+ SetLiveModels([]Model{{
130+ Provider: "custom-responses",
131+ ID: "reasoning-model",
132+ API: APIResponses,
133+ Reasoning: true,
134+ ReasoningLevelMap: map[string]string{"max": "max"},
135+ }})
136+ t.Cleanup(func() { SetLiveModels(nil) })
137+
138+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
139+ c := named.inner.(*codexClient)
140+ wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
145+ t.Fatalf("reasoning = %+v", wire.Reasoning)
146+ }
147+}
148+
128 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {149 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
129 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)150 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
130 c := named.inner.(*codexClient)151 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