1package provider
2
3import "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.
9func withLiveModels(t *testing.T, models []Model) {
10 t.Helper()
11 preserveActiveCatalog(t)
12 SetLiveModels(models)
13}
14
15// outputBudget pulls whichever max-output field buildRequest populated
16// for a non-reasoning model (out.MaxTokens) so tests can assert the
17// clamped value regardless of the reasoning path.
18func outputBudget(t *testing.T, out *oaiRequest) int {
19 t.Helper()
20 switch {
21 case out.MaxTokens != nil:
22 return *out.MaxTokens
23 case out.MaxCompletionTok != nil:
24 return *out.MaxCompletionTok
25 default:
26 t.Fatalf("no output budget set on request")
27 return 0
28 }
29}
30
31// TestBuildRequestDoesNotClampWhenOutputFitsWindow is the regression
32// guard for the original PR #24 behavior: a normal model whose MaxOutput
33// comfortably fits its context window must NOT have its budget reduced.
34// The earlier implementation subtracted 4096 from min(window, MaxOutput),
35// which silently shrank every OpenAI/DeepSeek model by 4096 tokens.
36func TestBuildRequestDoesNotClampWhenOutputFitsWindow(t *testing.T) {
37 withLiveModels(t, []Model{{
38 Provider: "openai",
39 ID: "fits-fine",
40 ContextWindow: 128000,
41 MaxOutput: 16384,
42 }})
43 c := &openaiClient{name: "openai"}
44
45 out, err := c.buildRequest(Request{
46 Model: "fits-fine",
47 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
48 })
49 if err != nil {
50 t.Fatal(err)
51 }
52 if got := outputBudget(t, out); got != 16384 {
53 t.Fatalf("output budget = %d; want 16384 (must not clamp when output fits window)", got)
54 }
55}
56
57// TestBuildRequestClampsLargeWindowAtMaxReserve covers the bug PR #24
58// targets for a large-window model whose MaxOutput equals its (served)
59// context window. Sending max_tokens == window leaves no room for input,
60// so OpenRouter rejects it. The proportional reserve (window/8) is capped
61// at 4096, so a 262144 window reserves the cap, not window/8.
62func TestBuildRequestClampsLargeWindowAtMaxReserve(t *testing.T) {
63 const window = 262144
64 withLiveModels(t, []Model{{
65 Provider: "openrouter",
66 ID: "nemotron-tight",
67 ContextWindow: window,
68 MaxOutput: window,
69 }})
70 c := &openaiClient{name: "openrouter"}
71
72 out, err := c.buildRequest(Request{
73 Model: "nemotron-tight",
74 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
75 })
76 if err != nil {
77 t.Fatal(err)
78 }
79 want := window - 4096 // window/8 = 32768 > 4096 cap, so reserve = 4096
80 if got := outputBudget(t, out); got != want {
81 t.Fatalf("output budget = %d; want %d (window - capped reserve)", got, want)
82 }
83}
84
85// TestBuildRequestProportionalReserveSmallWindow verifies the reserve is
86// proportional for small windows so they aren't over-penalized. gpt-4's
87// 8192/8192 model reserves window/8 = 1024 (not a flat 4096, which would
88// halve its budget), leaving 7168.
89func TestBuildRequestProportionalReserveSmallWindow(t *testing.T) {
90 const window = 8192
91 withLiveModels(t, []Model{{
92 Provider: "openai",
93 ID: "gpt-4-like",
94 ContextWindow: window,
95 MaxOutput: window,
96 }})
97 c := &openaiClient{name: "openai"}
98
99 out, err := c.buildRequest(Request{
100 Model: "gpt-4-like",
101 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
102 })
103 if err != nil {
104 t.Fatal(err)
105 }
106 want := window - window/8 // 8192 - 1024 = 7168
107 if got := outputBudget(t, out); got != want {
108 t.Fatalf("output budget = %d; want %d (window - window/8)", got, want)
109 }
110}
111
112// TestBuildRequestClampFloor verifies a tiny context window never yields
113// a zero or negative budget. window/8 of 16 is 2, leaving 14.
114func TestBuildRequestClampFloor(t *testing.T) {
115 withLiveModels(t, []Model{{
116 Provider: "openrouter",
117 ID: "tiny-window",
118 ContextWindow: 16,
119 MaxOutput: 16,
120 }})
121 c := &openaiClient{name: "openrouter"}
122
123 out, err := c.buildRequest(Request{
124 Model: "tiny-window",
125 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
126 })
127 if err != nil {
128 t.Fatal(err)
129 }
130 if got := outputBudget(t, out); got != 14 {
131 t.Fatalf("output budget = %d; want 14 (window - window/8, still positive)", got)
132 }
133}
134
135// TestBuildRequestClampDoesNotInflate confirms the clamp only ever
136// lowers the budget: a small explicit MaxTokens under the clamp ceiling
137// passes through untouched.
138func TestBuildRequestClampDoesNotInflate(t *testing.T) {
139 withLiveModels(t, []Model{{
140 Provider: "openrouter",
141 ID: "roomy",
142 ContextWindow: 262144,
143 MaxOutput: 262144,
144 }})
145 c := &openaiClient{name: "openrouter"}
146
147 out, err := c.buildRequest(Request{
148 Model: "roomy",
149 MaxTokens: 8000,
150 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
151 })
152 if err != nil {
153 t.Fatal(err)
154 }
155 if got := outputBudget(t, out); got != 8000 {
156 t.Fatalf("output budget = %d; want 8000 (explicit request below ceiling, unchanged)", got)
157 }
158}