detached ffca64d

Merge #24: clamp max_tokens to fit context window (proportional reserve)

Fixes OpenRouter rejecting requests where max_tokens equals the served
context window. Prefer top_provider.context_length on discovery and clamp
max_tokens to ContextWindow minus a proportional reserve (window/8 capped
at 4096). Reworked from the original PR so the reserve derives from the
window, not MaxOutput: models whose output already fits are untouched and
small-window models are not over-penalized.

Co-authored-by: Neil-urk12 <neil-urk12@users.noreply.github.com>
commit
ffca64d4fd14395faf18f1591723de2fcf33b9f7
author
patriceckhart · · 2026-06-09 17:29 UTC
merge of
c2c9a5e + d2fa182 · diff against the first parent
signature
unsigned
refs
tag: v0.2.24
4 files · +260 -1
M packages/provider/discover.go +1 -1
@@ -284,7 +284,7 @@ func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
284284 display = d.ID
285285 }
286286 ctxWin := d.ContextLength
287- if ctxWin == 0 {
287+ if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
288288 ctxWin = d.TopProvider.ContextLength
289289 }
290290 maxOut := 0
@@ -284,7 +284,7 @@ func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
284 display = d.ID284 display = d.ID
285 }285 }
286 ctxWin := d.ContextLength286 ctxWin := d.ContextLength
287- if ctxWin == 0 {287+ if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
288 ctxWin = d.TopProvider.ContextLength288 ctxWin = d.TopProvider.ContextLength
289 }289 }
290 maxOut := 0290 maxOut := 0
A packages/provider/discover_openrouter_test.go +53 -0
@@ -0,0 +1,53 @@
1+package provider
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+)
9+
10+// TestDiscoverOpenRouterPrefersServedContextLength verifies the
11+// discovery fix: OpenRouter reports an inflated model-level
12+// context_length (the model's theoretical max) alongside the serving
13+// provider's real top_provider.context_length. When the served limit is
14+// smaller, discovery must use it, because that's the limit OpenRouter
15+// actually enforces. Using the inflated value made every context-window
16+// check useless and let max_tokens exceed the real ceiling.
17+func TestDiscoverOpenRouterPrefersServedContextLength(t *testing.T) {
18+ const body = `{"data":[
19+ {"id":"infl","name":"Inflated","context_length":1000000,
20+ "top_provider":{"context_length":262144}},
21+ {"id":"served-bigger","name":"ServedBigger","context_length":131072,
22+ "top_provider":{"context_length":262144}},
23+ {"id":"no-top","name":"NoTop","context_length":128000,
24+ "top_provider":{"context_length":0}}
25+ ]}`
26+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ w.Header().Set("Content-Type", "application/json")
28+ _, _ = w.Write([]byte(body))
29+ }))
30+ defer srv.Close()
31+
32+ models, err := DiscoverOpenRouter(context.Background(), srv.URL)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ byID := map[string]Model{}
37+ for _, m := range models {
38+ byID[m.ID] = m
39+ }
40+
41+ // Inflated model-level window: use the smaller served limit.
42+ if got := byID["infl"].ContextWindow; got != 262144 {
43+ t.Errorf("inflated model ContextWindow = %d; want 262144 (served limit)", got)
44+ }
45+ // Served limit larger than model-level: keep the smaller model value.
46+ if got := byID["served-bigger"].ContextWindow; got != 131072 {
47+ t.Errorf("served-bigger ContextWindow = %d; want 131072 (model value, the smaller one)", got)
48+ }
49+ // No served limit: fall back to the model-level value.
50+ if got := byID["no-top"].ContextWindow; got != 128000 {
51+ t.Errorf("no-top ContextWindow = %d; want 128000 (model value, no top_provider)", got)
52+ }
53+}
@@ -0,0 +1,53 @@
1+package provider
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+)
9+
10+// TestDiscoverOpenRouterPrefersServedContextLength verifies the
11+// discovery fix: OpenRouter reports an inflated model-level
12+// context_length (the model's theoretical max) alongside the serving
13+// provider's real top_provider.context_length. When the served limit is
14+// smaller, discovery must use it, because that's the limit OpenRouter
15+// actually enforces. Using the inflated value made every context-window
16+// check useless and let max_tokens exceed the real ceiling.
17+func TestDiscoverOpenRouterPrefersServedContextLength(t *testing.T) {
18+ const body = `{"data":[
19+ {"id":"infl","name":"Inflated","context_length":1000000,
20+ "top_provider":{"context_length":262144}},
21+ {"id":"served-bigger","name":"ServedBigger","context_length":131072,
22+ "top_provider":{"context_length":262144}},
23+ {"id":"no-top","name":"NoTop","context_length":128000,
24+ "top_provider":{"context_length":0}}
25+ ]}`
26+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ w.Header().Set("Content-Type", "application/json")
28+ _, _ = w.Write([]byte(body))
29+ }))
30+ defer srv.Close()
31+
32+ models, err := DiscoverOpenRouter(context.Background(), srv.URL)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ byID := map[string]Model{}
37+ for _, m := range models {
38+ byID[m.ID] = m
39+ }
40+
41+ // Inflated model-level window: use the smaller served limit.
42+ if got := byID["infl"].ContextWindow; got != 262144 {
43+ t.Errorf("inflated model ContextWindow = %d; want 262144 (served limit)", got)
44+ }
45+ // Served limit larger than model-level: keep the smaller model value.
46+ if got := byID["served-bigger"].ContextWindow; got != 131072 {
47+ t.Errorf("served-bigger ContextWindow = %d; want 131072 (model value, the smaller one)", got)
48+ }
49+ // No served limit: fall back to the model-level value.
50+ if got := byID["no-top"].ContextWindow; got != 128000 {
51+ t.Errorf("no-top ContextWindow = %d; want 128000 (model value, no top_provider)", got)
52+ }
53+}
M packages/provider/openai.go +37 -0
@@ -183,6 +183,43 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
183183 if maxTok <= 0 {
184184 maxTok = m.MaxOutput
185185 }
186+ // Clamp max_tokens so output plus a minimum input reservation fits
187+ // within the context window. Some providers (OpenRouter) enforce
188+ // input + max_output <= context_length and reject requests where the
189+ // total exceeds it. Reserving headroom guarantees the system prompt,
190+ // first message, and tool definitions have room.
191+ //
192+ // The cap is derived from the context window, never from MaxOutput:
193+ // MaxOutput is already the output ceiling, so subtracting from it
194+ // would shrink every model's budget even when its output comfortably
195+ // fits the window. We only lower maxTok when ContextWindow - reserve
196+ // is actually tighter than the requested budget, which is exactly the
197+ // pathological case (e.g. a model whose MaxOutput equals its window).
198+ //
199+ // The reserve is proportional (window/8, capped at 4096) rather than a
200+ // flat 4096 so small-window models aren't over-penalized: a flat 4096
201+ // would halve gpt-4's 8192 budget, while window/8 reserves a sensible
202+ // 1024 there and still tops out at 4096 for large contexts.
203+ //
204+ // Some providers (OpenRouter) report inflated model-level context
205+ // windows (e.g. 1000000) while the serving provider enforces a much
206+ // tighter limit (e.g. 262144). Discovery already prefers the serving
207+ // provider's smaller context_length, so m.ContextWindow is the real
208+ // limit by the time we get here.
209+ if m.ContextWindow > 0 {
210+ reserve := m.ContextWindow / 8
211+ const maxReserve = 4096
212+ if reserve > maxReserve {
213+ reserve = maxReserve
214+ }
215+ clamped := m.ContextWindow - reserve
216+ if clamped < 1 {
217+ clamped = 1
218+ }
219+ if maxTok > clamped {
220+ maxTok = clamped
221+ }
222+ }
186223 if m.Reasoning {
187224 if maxTok > 0 {
188225 out.MaxCompletionTok = &maxTok
@@ -183,6 +183,43 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
183 if maxTok <= 0 {183 if maxTok <= 0 {
184 maxTok = m.MaxOutput184 maxTok = m.MaxOutput
185 }185 }
186+ // Clamp max_tokens so output plus a minimum input reservation fits
187+ // within the context window. Some providers (OpenRouter) enforce
188+ // input + max_output <= context_length and reject requests where the
189+ // total exceeds it. Reserving headroom guarantees the system prompt,
190+ // first message, and tool definitions have room.
191+ //
192+ // The cap is derived from the context window, never from MaxOutput:
193+ // MaxOutput is already the output ceiling, so subtracting from it
194+ // would shrink every model's budget even when its output comfortably
195+ // fits the window. We only lower maxTok when ContextWindow - reserve
196+ // is actually tighter than the requested budget, which is exactly the
197+ // pathological case (e.g. a model whose MaxOutput equals its window).
198+ //
199+ // The reserve is proportional (window/8, capped at 4096) rather than a
200+ // flat 4096 so small-window models aren't over-penalized: a flat 4096
201+ // would halve gpt-4's 8192 budget, while window/8 reserves a sensible
202+ // 1024 there and still tops out at 4096 for large contexts.
203+ //
204+ // Some providers (OpenRouter) report inflated model-level context
205+ // windows (e.g. 1000000) while the serving provider enforces a much
206+ // tighter limit (e.g. 262144). Discovery already prefers the serving
207+ // provider's smaller context_length, so m.ContextWindow is the real
208+ // limit by the time we get here.
209+ if m.ContextWindow > 0 {
210+ reserve := m.ContextWindow / 8
211+ const maxReserve = 4096
212+ if reserve > maxReserve {
213+ reserve = maxReserve
214+ }
215+ clamped := m.ContextWindow - reserve
216+ if clamped < 1 {
217+ clamped = 1
218+ }
219+ if maxTok > clamped {
220+ maxTok = clamped
221+ }
222+ }
186 if m.Reasoning {223 if m.Reasoning {
187 if maxTok > 0 {224 if maxTok > 0 {
188 out.MaxCompletionTok = &maxTok225 out.MaxCompletionTok = &maxTok
A packages/provider/openai_clamp_test.go +169 -0
@@ -0,0 +1,169 @@
1+package provider
2+
3+import "testing"
4+
5+// withLiveModels installs a synthetic catalog overlay for the duration
6+// of a test and restores the previous state afterwards. It lets these
7+// tests pin exact ContextWindow / MaxOutput values without depending on
8+// the real catalog.
9+func withLiveModels(t *testing.T, models []Model) {
10+ t.Helper()
11+ activeMu.Lock()
12+ prevActive := active
13+ prevSet := activeSet
14+ activeMu.Unlock()
15+
16+ SetLiveModels(models)
17+
18+ t.Cleanup(func() {
19+ activeMu.Lock()
20+ active = prevActive
21+ activeSet = prevSet
22+ activeMu.Unlock()
23+ })
24+}
25+
26+// outputBudget pulls whichever max-output field buildRequest populated
27+// for a non-reasoning model (out.MaxTokens) so tests can assert the
28+// clamped value regardless of the reasoning path.
29+func outputBudget(t *testing.T, out *oaiRequest) int {
30+ t.Helper()
31+ switch {
32+ case out.MaxTokens != nil:
33+ return *out.MaxTokens
34+ case out.MaxCompletionTok != nil:
35+ return *out.MaxCompletionTok
36+ default:
37+ t.Fatalf("no output budget set on request")
38+ return 0
39+ }
40+}
41+
42+// TestBuildRequestDoesNotClampWhenOutputFitsWindow is the regression
43+// guard for the original PR #24 behavior: a normal model whose MaxOutput
44+// comfortably fits its context window must NOT have its budget reduced.
45+// The earlier implementation subtracted 4096 from min(window, MaxOutput),
46+// which silently shrank every OpenAI/DeepSeek model by 4096 tokens.
47+func TestBuildRequestDoesNotClampWhenOutputFitsWindow(t *testing.T) {
48+ withLiveModels(t, []Model{{
49+ Provider: "openai",
50+ ID: "fits-fine",
51+ ContextWindow: 128000,
52+ MaxOutput: 16384,
53+ }})
54+ c := &openaiClient{name: "openai"}
55+
56+ out, err := c.buildRequest(Request{
57+ Model: "fits-fine",
58+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
59+ })
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ if got := outputBudget(t, out); got != 16384 {
64+ t.Fatalf("output budget = %d; want 16384 (must not clamp when output fits window)", got)
65+ }
66+}
67+
68+// TestBuildRequestClampsLargeWindowAtMaxReserve covers the bug PR #24
69+// targets for a large-window model whose MaxOutput equals its (served)
70+// context window. Sending max_tokens == window leaves no room for input,
71+// so OpenRouter rejects it. The proportional reserve (window/8) is capped
72+// at 4096, so a 262144 window reserves the cap, not window/8.
73+func TestBuildRequestClampsLargeWindowAtMaxReserve(t *testing.T) {
74+ const window = 262144
75+ withLiveModels(t, []Model{{
76+ Provider: "openrouter",
77+ ID: "nemotron-tight",
78+ ContextWindow: window,
79+ MaxOutput: window,
80+ }})
81+ c := &openaiClient{name: "openrouter"}
82+
83+ out, err := c.buildRequest(Request{
84+ Model: "nemotron-tight",
85+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
86+ })
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ want := window - 4096 // window/8 = 32768 > 4096 cap, so reserve = 4096
91+ if got := outputBudget(t, out); got != want {
92+ t.Fatalf("output budget = %d; want %d (window - capped reserve)", got, want)
93+ }
94+}
95+
96+// TestBuildRequestProportionalReserveSmallWindow verifies the reserve is
97+// proportional for small windows so they aren't over-penalized. gpt-4's
98+// 8192/8192 model reserves window/8 = 1024 (not a flat 4096, which would
99+// halve its budget), leaving 7168.
100+func TestBuildRequestProportionalReserveSmallWindow(t *testing.T) {
101+ const window = 8192
102+ withLiveModels(t, []Model{{
103+ Provider: "openai",
104+ ID: "gpt-4-like",
105+ ContextWindow: window,
106+ MaxOutput: window,
107+ }})
108+ c := &openaiClient{name: "openai"}
109+
110+ out, err := c.buildRequest(Request{
111+ Model: "gpt-4-like",
112+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
113+ })
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ want := window - window/8 // 8192 - 1024 = 7168
118+ if got := outputBudget(t, out); got != want {
119+ t.Fatalf("output budget = %d; want %d (window - window/8)", got, want)
120+ }
121+}
122+
123+// TestBuildRequestClampFloor verifies a tiny context window never yields
124+// a zero or negative budget. window/8 of 16 is 2, leaving 14.
125+func TestBuildRequestClampFloor(t *testing.T) {
126+ withLiveModels(t, []Model{{
127+ Provider: "openrouter",
128+ ID: "tiny-window",
129+ ContextWindow: 16,
130+ MaxOutput: 16,
131+ }})
132+ c := &openaiClient{name: "openrouter"}
133+
134+ out, err := c.buildRequest(Request{
135+ Model: "tiny-window",
136+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
137+ })
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ if got := outputBudget(t, out); got != 14 {
142+ t.Fatalf("output budget = %d; want 14 (window - window/8, still positive)", got)
143+ }
144+}
145+
146+// TestBuildRequestClampDoesNotInflate confirms the clamp only ever
147+// lowers the budget: a small explicit MaxTokens under the clamp ceiling
148+// passes through untouched.
149+func TestBuildRequestClampDoesNotInflate(t *testing.T) {
150+ withLiveModels(t, []Model{{
151+ Provider: "openrouter",
152+ ID: "roomy",
153+ ContextWindow: 262144,
154+ MaxOutput: 262144,
155+ }})
156+ c := &openaiClient{name: "openrouter"}
157+
158+ out, err := c.buildRequest(Request{
159+ Model: "roomy",
160+ MaxTokens: 8000,
161+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
162+ })
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ if got := outputBudget(t, out); got != 8000 {
167+ t.Fatalf("output budget = %d; want 8000 (explicit request below ceiling, unchanged)", got)
168+ }
169+}
@@ -0,0 +1,169 @@
1+package provider
2+
3+import "testing"
4+
5+// withLiveModels installs a synthetic catalog overlay for the duration
6+// of a test and restores the previous state afterwards. It lets these
7+// tests pin exact ContextWindow / MaxOutput values without depending on
8+// the real catalog.
9+func withLiveModels(t *testing.T, models []Model) {
10+ t.Helper()
11+ activeMu.Lock()
12+ prevActive := active
13+ prevSet := activeSet
14+ activeMu.Unlock()
15+
16+ SetLiveModels(models)
17+
18+ t.Cleanup(func() {
19+ activeMu.Lock()
20+ active = prevActive
21+ activeSet = prevSet
22+ activeMu.Unlock()
23+ })
24+}
25+
26+// outputBudget pulls whichever max-output field buildRequest populated
27+// for a non-reasoning model (out.MaxTokens) so tests can assert the
28+// clamped value regardless of the reasoning path.
29+func outputBudget(t *testing.T, out *oaiRequest) int {
30+ t.Helper()
31+ switch {
32+ case out.MaxTokens != nil:
33+ return *out.MaxTokens
34+ case out.MaxCompletionTok != nil:
35+ return *out.MaxCompletionTok
36+ default:
37+ t.Fatalf("no output budget set on request")
38+ return 0
39+ }
40+}
41+
42+// TestBuildRequestDoesNotClampWhenOutputFitsWindow is the regression
43+// guard for the original PR #24 behavior: a normal model whose MaxOutput
44+// comfortably fits its context window must NOT have its budget reduced.
45+// The earlier implementation subtracted 4096 from min(window, MaxOutput),
46+// which silently shrank every OpenAI/DeepSeek model by 4096 tokens.
47+func TestBuildRequestDoesNotClampWhenOutputFitsWindow(t *testing.T) {
48+ withLiveModels(t, []Model{{
49+ Provider: "openai",
50+ ID: "fits-fine",
51+ ContextWindow: 128000,
52+ MaxOutput: 16384,
53+ }})
54+ c := &openaiClient{name: "openai"}
55+
56+ out, err := c.buildRequest(Request{
57+ Model: "fits-fine",
58+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
59+ })
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ if got := outputBudget(t, out); got != 16384 {
64+ t.Fatalf("output budget = %d; want 16384 (must not clamp when output fits window)", got)
65+ }
66+}
67+
68+// TestBuildRequestClampsLargeWindowAtMaxReserve covers the bug PR #24
69+// targets for a large-window model whose MaxOutput equals its (served)
70+// context window. Sending max_tokens == window leaves no room for input,
71+// so OpenRouter rejects it. The proportional reserve (window/8) is capped
72+// at 4096, so a 262144 window reserves the cap, not window/8.
73+func TestBuildRequestClampsLargeWindowAtMaxReserve(t *testing.T) {
74+ const window = 262144
75+ withLiveModels(t, []Model{{
76+ Provider: "openrouter",
77+ ID: "nemotron-tight",
78+ ContextWindow: window,
79+ MaxOutput: window,
80+ }})
81+ c := &openaiClient{name: "openrouter"}
82+
83+ out, err := c.buildRequest(Request{
84+ Model: "nemotron-tight",
85+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
86+ })
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ want := window - 4096 // window/8 = 32768 > 4096 cap, so reserve = 4096
91+ if got := outputBudget(t, out); got != want {
92+ t.Fatalf("output budget = %d; want %d (window - capped reserve)", got, want)
93+ }
94+}
95+
96+// TestBuildRequestProportionalReserveSmallWindow verifies the reserve is
97+// proportional for small windows so they aren't over-penalized. gpt-4's
98+// 8192/8192 model reserves window/8 = 1024 (not a flat 4096, which would
99+// halve its budget), leaving 7168.
100+func TestBuildRequestProportionalReserveSmallWindow(t *testing.T) {
101+ const window = 8192
102+ withLiveModels(t, []Model{{
103+ Provider: "openai",
104+ ID: "gpt-4-like",
105+ ContextWindow: window,
106+ MaxOutput: window,
107+ }})
108+ c := &openaiClient{name: "openai"}
109+
110+ out, err := c.buildRequest(Request{
111+ Model: "gpt-4-like",
112+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
113+ })
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ want := window - window/8 // 8192 - 1024 = 7168
118+ if got := outputBudget(t, out); got != want {
119+ t.Fatalf("output budget = %d; want %d (window - window/8)", got, want)
120+ }
121+}
122+
123+// TestBuildRequestClampFloor verifies a tiny context window never yields
124+// a zero or negative budget. window/8 of 16 is 2, leaving 14.
125+func TestBuildRequestClampFloor(t *testing.T) {
126+ withLiveModels(t, []Model{{
127+ Provider: "openrouter",
128+ ID: "tiny-window",
129+ ContextWindow: 16,
130+ MaxOutput: 16,
131+ }})
132+ c := &openaiClient{name: "openrouter"}
133+
134+ out, err := c.buildRequest(Request{
135+ Model: "tiny-window",
136+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
137+ })
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ if got := outputBudget(t, out); got != 14 {
142+ t.Fatalf("output budget = %d; want 14 (window - window/8, still positive)", got)
143+ }
144+}
145+
146+// TestBuildRequestClampDoesNotInflate confirms the clamp only ever
147+// lowers the budget: a small explicit MaxTokens under the clamp ceiling
148+// passes through untouched.
149+func TestBuildRequestClampDoesNotInflate(t *testing.T) {
150+ withLiveModels(t, []Model{{
151+ Provider: "openrouter",
152+ ID: "roomy",
153+ ContextWindow: 262144,
154+ MaxOutput: 262144,
155+ }})
156+ c := &openaiClient{name: "openrouter"}
157+
158+ out, err := c.buildRequest(Request{
159+ Model: "roomy",
160+ MaxTokens: 8000,
161+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
162+ })
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ if got := outputBudget(t, out); got != 8000 {
167+ t.Fatalf("output budget = %d; want 8000 (explicit request below ceiling, unchanged)", got)
168+ }
169+}

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