fix(provider): clamp max_tokens to fit context window with proportional reserve
OpenRouter enforces input + max_output <= served context_length and rejects requests where max_tokens equals the whole window, which happens for models whose catalog MaxOutput is set equal to ContextWindow (e.g. nemotron-3-super-120B). Two parts: - discover.go (from #24): prefer top_provider.context_length when it is smaller than the inflated model-level context_length, so ContextWindow reflects the limit OpenRouter actually serves. - openai.go: clamp max_tokens to ContextWindow minus a reserve. The reserve is derived from the window (window/8, capped at 4096), never from MaxOutput, so models whose output already fits the window are untouched and small-window models (gpt-4) are not over-penalized. Adds buildRequest clamp tests (fits-window no-op, large-window cap, small-window proportional reserve, floor, explicit-request passthrough) and an httptest-based DiscoverOpenRouter test for the served-context preference. Co-authored-by: Neil-urk12 <neil-urk12@users.noreply.github.com>
- commit
- d2fa18270d904c5762e03bc2d051e9754ed3025e
- author
- patriceckhart · · 2026-06-09 17:29 UTC
- parent
- b680083
- signature
- unsigned
3 files · +251 -17
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 +29 -17
| @@ -183,26 +183,38 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 183 | 183 | if maxTok <= 0 { | |
| 184 | 184 | maxTok = m.MaxOutput | |
| 185 | 185 | } | |
| 186 | - | // Clamp max_tokens so output + minimum input fits within the context | |
| 187 | - | // window. Some providers (OpenRouter) enforce input + max_output <= | |
| 188 | - | // context_length and reject requests where the total exceeds it. | |
| 189 | - | // This reservation ensures basic user input (system prompt + first | |
| 190 | - | // message + tool definitions) has room. | |
| 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 | 191 | // | |
| 192 | - | // Use the smaller of ContextWindow and MaxOutput as the cap because | |
| 193 | - | // some providers (OpenRouter) report inflated model-level context | |
| 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 | |
| 194 | 205 | // windows (e.g. 1000000) while the serving provider enforces a much | |
| 195 | - | // tighter limit (e.g. 262144). When both are wrong we still land on | |
| 196 | - | // the output cap, which is the actual limit reported by the provider. | |
| 197 | - | if m.ContextWindow > 0 && m.MaxOutput > 0 { | |
| 198 | - | ctxCap := m.ContextWindow | |
| 199 | - | if m.MaxOutput < ctxCap { | |
| 200 | - | ctxCap = m.MaxOutput | |
| 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 | |
| 201 | 214 | } | |
| 202 | - | const reserve = 4096 | |
| 203 | - | clamped := ctxCap - reserve | |
| 204 | - | if clamped < 1024 { | |
| 205 | - | clamped = 1024 | |
| 215 | + | clamped := m.ContextWindow - reserve | |
| 216 | + | if clamped < 1 { | |
| 217 | + | clamped = 1 | |
| 206 | 218 | } | |
| 207 | 219 | if maxTok > clamped { | |
| 208 | 220 | maxTok = clamped | |
| @@ -183,26 +183,38 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 183 | if maxTok <= 0 { | 183 | if maxTok <= 0 { | ||
| 184 | maxTok = m.MaxOutput | 184 | maxTok = m.MaxOutput | ||
| 185 | } | 185 | } | ||
| 186 | - | // Clamp max_tokens so output + minimum input fits within the context | 186 | + | // Clamp max_tokens so output plus a minimum input reservation fits |
| 187 | - | // window. Some providers (OpenRouter) enforce input + max_output <= | 187 | + | // within the context window. Some providers (OpenRouter) enforce |
| 188 | - | // context_length and reject requests where the total exceeds it. | 188 | + | // input + max_output <= context_length and reject requests where the |
| 189 | - | // This reservation ensures basic user input (system prompt + first | 189 | + | // total exceeds it. Reserving headroom guarantees the system prompt, |
| 190 | - | // message + tool definitions) has room. | 190 | + | // first message, and tool definitions have room. |
| 191 | // | 191 | // | ||
| 192 | - | // Use the smaller of ContextWindow and MaxOutput as the cap because | 192 | + | // The cap is derived from the context window, never from MaxOutput: |
| 193 | - | // some providers (OpenRouter) report inflated model-level context | 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 | |||
| 194 | // windows (e.g. 1000000) while the serving provider enforces a much | 205 | // windows (e.g. 1000000) while the serving provider enforces a much | ||
| 195 | - | // tighter limit (e.g. 262144). When both are wrong we still land on | 206 | + | // tighter limit (e.g. 262144). Discovery already prefers the serving |
| 196 | - | // the output cap, which is the actual limit reported by the provider. | 207 | + | // provider's smaller context_length, so m.ContextWindow is the real |
| 197 | - | if m.ContextWindow > 0 && m.MaxOutput > 0 { | 208 | + | // limit by the time we get here. |
| 198 | - | ctxCap := m.ContextWindow | 209 | + | if m.ContextWindow > 0 { |
| 199 | - | if m.MaxOutput < ctxCap { | 210 | + | reserve := m.ContextWindow / 8 |
| 200 | - | ctxCap = m.MaxOutput | 211 | + | const maxReserve = 4096 |
| 212 | + | if reserve > maxReserve { | |||
| 213 | + | reserve = maxReserve | |||
| 201 | } | 214 | } | ||
| 202 | - | const reserve = 4096 | 215 | + | clamped := m.ContextWindow - reserve |
| 203 | - | clamped := ctxCap - reserve | 216 | + | if clamped < 1 { |
| 204 | - | if clamped < 1024 { | 217 | + | clamped = 1 |
| 205 | - | clamped = 1024 | |||
| 206 | } | 218 | } | ||
| 207 | if maxTok > clamped { | 219 | if maxTok > clamped { | ||
| 208 | maxTok = clamped | 220 | maxTok = clamped | ||
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 | + | } | |||