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 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.
29func 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.
47func 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.
73func 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.
100func 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.
125func 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.
149func 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}