1package provider
2
3import (
4 "context"
5 "math"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10)
11
12func TestSSEParse(t *testing.T) {
13 r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n")
14 ch := make(chan sseEvent, 4)
15 go readSSE(r, ch)
16
17 e := <-ch
18 if e.Event != "foo" || e.Data != `{"a":1}` {
19 t.Fatalf("event 1: %+v", e)
20 }
21 e = <-ch
22 if e.Event != "" || e.Data != "hello\nworld" {
23 t.Fatalf("event 2: %+v", e)
24 }
25 if _, ok := <-ch; ok {
26 t.Fatalf("channel not closed")
27 }
28}
29
30func TestModelCatalog(t *testing.T) {
31 if len(Catalog) == 0 {
32 t.Fatal("empty catalog")
33 }
34 if _, err := FindModel("anthropic", "claude-sonnet-4-5"); err != nil {
35 t.Fatal(err)
36 }
37 if _, err := FindModel("openai", "gpt-5"); err != nil {
38 t.Fatal(err)
39 }
40 if _, err := FindModel("", "nope"); err == nil {
41 t.Fatal("expected error")
42 }
43}
44
45func TestVertexGeminiFlashCatalogLimits(t *testing.T) {
46 for _, id := range []string{"gemini-3.1-flash-lite", "gemini-3.5-flash"} {
47 m, err := FindModel("google-vertex", id)
48 if err != nil {
49 t.Fatal(err)
50 }
51 if m.ContextWindow != 1048576 || m.MaxOutput != 65535 || !m.Reasoning {
52 t.Errorf("%s metadata = %+v", id, m)
53 }
54 }
55}
56
57func TestComputeCost(t *testing.T) {
58 m, _ := FindModel("anthropic", "claude-sonnet-4-5")
59 cost := ComputeCost(m, Usage{InputTokens: 1_000_000, OutputTokens: 1_000_000})
60 want := m.PriceInput + m.PriceOutput
61 if cost != want {
62 t.Fatalf("cost=%v want=%v", cost, want)
63 }
64}
65
66func TestComputeCostInputTier(t *testing.T) {
67 m := Model{
68 PriceInput: 1, PriceOutput: 2, PriceCacheRead: 0.1,
69 PriceTierInputTokens: 100,
70 PriceInputAbove: 3, PriceOutputAbove: 4, PriceCacheReadAbove: 0.2,
71 }
72 below := ComputeCost(m, Usage{InputTokens: 50, OutputTokens: 10, CacheReadTokens: 50})
73 wantBelow := (50.0*1 + 10*2 + 50*0.1) / 1_000_000
74 if math.Abs(below-wantBelow) > 1e-12 {
75 t.Fatalf("below-tier cost=%v want=%v", below, wantBelow)
76 }
77
78 above := ComputeCost(m, Usage{InputTokens: 51, OutputTokens: 10, CacheReadTokens: 50})
79 wantAbove := (51.0*3 + 10*4 + 50*0.2) / 1_000_000
80 if math.Abs(above-wantAbove) > 1e-12 {
81 t.Fatalf("above-tier cost=%v want=%v", above, wantAbove)
82 }
83}
84
85func TestAnthropicErrorStatus(t *testing.T) {
86 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 w.WriteHeader(http.StatusUnauthorized)
88 _, _ = w.Write([]byte(`{"error":{"type":"auth","message":"bad key"}}`))
89 }))
90 defer srv.Close()
91
92 c := NewAnthropic("x", srv.URL)
93 _, err := c.Stream(context.Background(), Request{Model: "claude-sonnet-4-5"})
94 if err == nil || !strings.Contains(err.Error(), "401") {
95 t.Fatalf("want 401 err, got %v", err)
96 }
97}
98
99func TestOpenAIErrorStatus(t *testing.T) {
100 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101 w.WriteHeader(http.StatusBadRequest)
102 _, _ = w.Write([]byte(`{"error":{"message":"nope"}}`))
103 }))
104 defer srv.Close()
105
106 c := NewOpenAI("x", srv.URL)
107 _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
108 if err == nil || !strings.Contains(err.Error(), "400") {
109 t.Fatalf("want 400 err, got %v", err)
110 }
111}
112
113func TestOpenAIUserContentPreservesProjectedTextBlocks(t *testing.T) {
114 c := NewOpenAI("x", "").(*openaiClient)
115 wire, err := c.buildRequest(Request{
116 Model: "gpt-5",
117 Messages: []Message{{
118 Role: RoleUser,
119 Content: []Content{
120 TextBlock{Text: "[message time: 2026-08-08T12:34:56-07:00]"},
121 TextBlock{Text: "fail"},
122 },
123 }},
124 })
125 if err != nil {
126 t.Fatal(err)
127 }
128 blocks, ok := wire.Messages[0].Content.([]interface{})
129 if !ok {
130 t.Fatalf("user content = %T, want structured text blocks", wire.Messages[0].Content)
131 }
132 if len(blocks) != 2 {
133 t.Fatalf("user content blocks = %d, want 2", len(blocks))
134 }
135 if got := blocks[0].(oaiContentText).Text; got != "[message time: 2026-08-08T12:34:56-07:00]" {
136 t.Fatalf("timestamp block = %q", got)
137 }
138 if got := blocks[1].(oaiContentText).Text; got != "fail" {
139 t.Fatalf("user text block = %q", got)
140 }
141}
142
143func TestMiniMaxM3CatalogAndRequest(t *testing.T) {
144 for _, provider := range []string{"minimax", "minimax-cn"} {
145 m, err := FindModel(provider, "MiniMax-M3")
146 if err != nil {
147 t.Fatal(err)
148 }
149 if m.ContextWindow != 1000000 || m.MaxOutput != 131072 || !m.Reasoning || !m.AdaptiveThinkingCompat {
150 t.Errorf("%s metadata = %+v", provider, m)
151 }
152 if m.PriceInput != 0.3 || m.PriceOutput != 1.2 || m.PriceCacheRead != 0.06 || m.PriceTierInputTokens != 512000 || m.PriceInputAbove != 0.6 || m.PriceOutputAbove != 2.4 || m.PriceCacheReadAbove != 0.12 {
153 t.Errorf("%s pricing = %+v", provider, m)
154 }
155 }
156
157 c := NewAnthropicCompat("minimax", "x", "https://api.minimax.io/anthropic").(*anthropicClient)
158 temp := float32(0.7)
159 wire, err := c.buildRequest(Request{
160 Model: "MiniMax-M3",
161 Reasoning: "high",
162 Temperature: &temp,
163 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
164 })
165 if err != nil {
166 t.Fatal(err)
167 }
168 if wire.Thinking == nil || wire.Thinking.Type != "adaptive" || wire.Thinking.BudgetTokens != 0 {
169 t.Fatalf("want adaptive thinking without a budget, got %+v", wire.Thinking)
170 }
171 if wire.OutputConfig != nil {
172 t.Fatalf("MiniMax-M3 must not send output_config, got %+v", wire.OutputConfig)
173 }
174 if wire.Temperature == nil || *wire.Temperature != temp {
175 t.Fatalf("MiniMax-M3 should keep temperature, got %v", wire.Temperature)
176 }
177}
178
179func TestAnthropicAdaptiveThinking(t *testing.T) {
180 c := NewAnthropic("x", "").(*anthropicClient)
181 temp := float32(0.7)
182
183 // Opus 5 -> adaptive thinking, effort set, no budget, no temperature.
184 wire, err := c.buildRequest(Request{
185 Model: "claude-opus-5",
186 Reasoning: "high",
187 Temperature: &temp,
188 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
189 })
190 if err != nil {
191 t.Fatal(err)
192 }
193 if wire.Thinking == nil || wire.Thinking.Type != "adaptive" {
194 t.Fatalf("want adaptive thinking, got %+v", wire.Thinking)
195 }
196 if wire.Thinking.BudgetTokens != 0 {
197 t.Fatalf("adaptive must not send budget_tokens, got %d", wire.Thinking.BudgetTokens)
198 }
199 if wire.OutputConfig == nil || wire.OutputConfig.Effort != "high" {
200 t.Fatalf("want effort=high, got %+v", wire.OutputConfig)
201 }
202 if wire.Temperature != nil {
203 t.Fatalf("adaptive must drop temperature, got %v", *wire.Temperature)
204 }
205
206 // maximum -> xhigh effort.
207 wire, err = c.buildRequest(Request{
208 Model: "claude-opus-5",
209 Reasoning: "maximum",
210 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
211 })
212 if err != nil {
213 t.Fatal(err)
214 }
215 if wire.OutputConfig == nil || wire.OutputConfig.Effort != "xhigh" {
216 t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig)
217 }
218
219 // max is a separate native tier above xhigh on adaptive models.
220 wire, err = c.buildRequest(Request{
221 Model: "claude-opus-5",
222 Reasoning: "max",
223 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
224 })
225 if err != nil {
226 t.Fatal(err)
227 }
228 if wire.OutputConfig == nil || wire.OutputConfig.Effort != "max" {
229 t.Fatalf("want effort=max, got %+v", wire.OutputConfig)
230 }
231
232 // Sonnet 5 -> adaptive thinking, effort set, no budget, no temperature.
233 wire, err = c.buildRequest(Request{
234 Model: "claude-sonnet-5",
235 Reasoning: "high",
236 Temperature: &temp,
237 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
238 })
239 if err != nil {
240 t.Fatal(err)
241 }
242 if wire.Thinking == nil || wire.Thinking.Type != "adaptive" {
243 t.Fatalf("want adaptive thinking for Sonnet 5, got %+v", wire.Thinking)
244 }
245 if wire.Thinking.BudgetTokens != 0 {
246 t.Fatalf("Sonnet 5 adaptive must not send budget_tokens, got %d", wire.Thinking.BudgetTokens)
247 }
248 if wire.OutputConfig == nil || wire.OutputConfig.Effort != "high" {
249 t.Fatalf("want Sonnet 5 effort=high, got %+v", wire.OutputConfig)
250 }
251 if wire.Temperature != nil {
252 t.Fatalf("Sonnet 5 adaptive must drop temperature, got %v", *wire.Temperature)
253 }
254
255 // Opus 4.5 -> budget-based thinking, no output_config, temperature kept.
256 wire, err = c.buildRequest(Request{
257 Model: "claude-opus-4-5",
258 Reasoning: "high",
259 Temperature: &temp,
260 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
261 })
262 if err != nil {
263 t.Fatal(err)
264 }
265 if wire.Thinking == nil || wire.Thinking.Type != "enabled" || wire.Thinking.BudgetTokens <= 0 {
266 t.Fatalf("want budget thinking, got %+v", wire.Thinking)
267 }
268 if wire.OutputConfig != nil {
269 t.Fatalf("budget models must not send output_config, got %+v", wire.OutputConfig)
270 }
271 if wire.Temperature == nil || *wire.Temperature != temp {
272 t.Fatalf("budget model should keep temperature, got %v", wire.Temperature)
273 }
274}
275
276func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) {
277 c := NewAnthropic("x", "").(*anthropicClient)
278 wire, err := c.buildRequest(Request{
279 Model: "claude-sonnet-4-5",
280 Messages: []Message{
281 {Role: RoleUser, Content: []Content{TextBlock{Text: "make an image"}}},
282 {Role: RoleAssistant, Content: []Content{
283 TextBlock{Text: "done"},
284 ImageBlock{MimeType: "image/png", Data: []byte("png")},
285 TextBlock{Text: "Saved image: `zut-gemini-image-x.png`"},
286 }},
287 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
288 },
289 })
290 if err != nil {
291 t.Fatal(err)
292 }
293 if len(wire.Messages) != 3 {
294 t.Fatalf("messages=%d", len(wire.Messages))
295 }
296 assistant := wire.Messages[1]
297 if assistant.Role != "assistant" {
298 t.Fatalf("role=%q", assistant.Role)
299 }
300 if len(assistant.Content) != 2 {
301 t.Fatalf("assistant content=%+v", assistant.Content)
302 }
303 for _, b := range assistant.Content {
304 if _, ok := b.(anthImageBlock); ok {
305 t.Fatalf("assistant image block was not stripped: %+v", assistant.Content)
306 }
307 }
308}
309
310func TestAnthropicStreamHappyPath(t *testing.T) {
311 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
312 w.Header().Set("content-type", "text/event-stream")
313 w.WriteHeader(http.StatusOK)
314 fl, _ := w.(http.Flusher)
315 write := func(s string) {
316 _, _ = w.Write([]byte(s))
317 if fl != nil {
318 fl.Flush()
319 }
320 }
321 write("event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n")
322 write("event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")
323 write("event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n")
324 write("event: content_block_stop\ndata: {\"index\":0}\n\n")
325 write("event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n")
326 write("event: message_stop\ndata: {}\n\n")
327 }))
328 defer srv.Close()
329
330 c := NewAnthropic("x", srv.URL)
331 evs, err := c.Stream(context.Background(), Request{Model: "claude-sonnet-4-5"})
332 if err != nil {
333 t.Fatal(err)
334 }
335 var gotText string
336 var done EventDone
337 for ev := range evs {
338 switch e := ev.(type) {
339 case EventTextDelta:
340 gotText += e.Delta
341 case EventDone:
342 done = e
343 }
344 }
345 if gotText != "hi" {
346 t.Fatalf("text=%q", gotText)
347 }
348 if done.Stop != StopEnd {
349 t.Fatalf("stop=%v", done.Stop)
350 }
351}
352
353func TestClaudeOpus5Catalog(t *testing.T) {
354 m, err := FindModel("anthropic", "claude-opus-5")
355 if err != nil {
356 t.Fatal(err)
357 }
358 if m.DisplayName != "Claude Opus 5" || m.ContextWindow != 1000000 || m.MaxOutput != 128000 || !m.Reasoning || !m.AdaptiveThinking || m.Speculative {
359 t.Fatalf("unexpected Opus 5 model: %+v", m)
360 }
361 if m.PriceInput != 5 || m.PriceOutput != 25 || m.PriceCacheRead != 0.5 || m.PriceCacheWrite != 6.25 {
362 t.Fatalf("unexpected Opus 5 pricing: %+v", m)
363 }
364}
365
366func TestClaudeSonnet5Catalog(t *testing.T) {
367 m, err := FindModel("anthropic", "claude-sonnet-5")
368 if err != nil {
369 t.Fatal(err)
370 }
371 if m.DisplayName != "Claude Sonnet 5" || m.ContextWindow != 1000000 || m.MaxOutput != 128000 || !m.Reasoning || !m.AdaptiveThinking {
372 t.Fatalf("unexpected Sonnet 5 model: %+v", m)
373 }
374 if m.PriceInput != 2 || m.PriceOutput != 10 || m.PriceCacheRead != 0.2 || m.PriceCacheWrite != 2.5 {
375 t.Fatalf("unexpected Sonnet 5 pricing: %+v", m)
376 }
377}
378
379func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) {
380 c := NewOpenRouter("token", "").(*openaiClient)
381 wire, err := c.buildRequest(Request{
382 Model: "anthropic/claude-opus-4.8",
383 Reasoning: "maximum",
384 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
385 })
386 if err != nil {
387 t.Fatal(err)
388 }
389 if wire.ReasoningEffort != "xhigh" {
390 t.Fatalf("want xhigh for adaptive Anthropic model over OpenAI-compatible wire, got %q", wire.ReasoningEffort)
391 }
392
393 wire, err = c.buildRequest(Request{
394 Model: "anthropic/claude-sonnet-5",
395 Reasoning: "maximum",
396 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
397 })
398 if err != nil {
399 t.Fatal(err)
400 }
401 if wire.ReasoningEffort != "xhigh" {
402 t.Fatalf("want xhigh for Claude Sonnet 5 over OpenAI-compatible wire, got %q", wire.ReasoningEffort)
403 }
404
405 wire, err = c.buildRequest(Request{
406 Model: "gpt-5.1",
407 Reasoning: "maximum",
408 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
409 })
410 if err != nil {
411 t.Fatal(err)
412 }
413 if wire.ReasoningEffort != "high" {
414 t.Fatalf("want generic OpenAI-compatible maximum clamped to high, got %q", wire.ReasoningEffort)
415 }
416}
417
418func TestOpenAIBuildRequestSkipsReasoningOnlyAssistantMessages(t *testing.T) {
419 c := NewKimi("token", "").(*openaiClient)
420 wire, err := c.buildRequest(Request{
421 Model: "kimi-for-coding",
422 Messages: []Message{
423 {Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}},
424 {Role: RoleAssistant, Content: []Content{ReasoningBlock{Summary: "thinking only"}}},
425 {Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
426 },
427 })
428 if err != nil {
429 t.Fatal(err)
430 }
431 for i, msg := range wire.Messages {
432 if msg.Role == "assistant" && msg.Content == nil && len(msg.ToolCalls) == 0 {
433 t.Fatalf("message %d is empty assistant: %+v", i, msg)
434 }
435 }
436 if got := len(wire.Messages); got != 2 {
437 t.Fatalf("messages=%d want 2 after skipping reasoning-only assistant", got)
438 }
439}
440
441func TestOpenAIStreamHappyPath(t *testing.T) {
442 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
443 w.Header().Set("content-type", "text/event-stream")
444 w.WriteHeader(http.StatusOK)
445 fl, _ := w.(http.Flusher)
446 write := func(s string) {
447 _, _ = w.Write([]byte(s))
448 if fl != nil {
449 fl.Flush()
450 }
451 }
452 write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n")
453 write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n")
454 write("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":2}}\n\n")
455 write("data: [DONE]\n\n")
456 }))
457 defer srv.Close()
458
459 c := NewOpenAI("x", srv.URL)
460 evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
461 if err != nil {
462 t.Fatal(err)
463 }
464 var gotText string
465 var done EventDone
466 for ev := range evs {
467 switch e := ev.(type) {
468 case EventTextDelta:
469 gotText += e.Delta
470 case EventDone:
471 done = e
472 }
473 }
474 if gotText != "hello" {
475 t.Fatalf("text=%q", gotText)
476 }
477 if done.Stop != StopEnd {
478 t.Fatalf("stop=%v", done.Stop)
479 }
480}
481
482func TestDiscoverOpenRouter(t *testing.T) {
483 const body = `{"data":[
484 {"id":"x/full","pricing":{"prompt":"0.000003","completion":"0.000015"},
485 "context_length":200000,"top_provider":{"max_completion_tokens":64000},
486 "supported_parameters":["reasoning"]},
487 {"id":"x/fallback","top_provider":{"context_length":128000}},
488 {"id":""}]}`
489 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
490 _, _ = w.Write([]byte(body))
491 }))
492 defer srv.Close()
493
494 models, err := DiscoverOpenRouter(context.Background(), srv.URL)
495 if err != nil {
496 t.Fatal(err)
497 }
498 if len(models) != 2 { // empty id dropped
499 t.Fatalf("want 2 models, got %d", len(models))
500 }
501 // per-token USD -> per-1M; reasoning from supported_parameters.
502 if m := models[0]; m.Provider != "openrouter" || m.ContextWindow != 200000 ||
503 m.MaxOutput != 64000 || !m.Reasoning || m.PriceInput != 3 || m.PriceOutput != 15 {
504 t.Errorf("model[0]: %+v", m)
505 }
506 // context falls back to top_provider; no reasoning.
507 if m := models[1]; m.ContextWindow != 128000 || m.MaxOutput != 0 || m.Reasoning {
508 t.Errorf("model[1]: %+v", m)
509 }
510}
511
512func TestGPT56CatalogEntries(t *testing.T) {
513 cases := []struct {
514 provider string
515 id string
516 context int
517 priceInput float64
518 priceOut float64
519 cacheRead float64
520 cacheWrite float64
521 }{
522 {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
523 {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
524 {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
525 {"openai-codex", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
526 {"openai-codex", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
527 {"openai-codex", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
528 {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
529 {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
530 {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},
531 {"vercel-ai-gateway", "openai/gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
532 {"vercel-ai-gateway", "openai/gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
533 {"vercel-ai-gateway", "openai/gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},
534 }
535 for _, tc := range cases {
536 m, err := FindModel(tc.provider, tc.id)
537 if err != nil {
538 t.Fatal(err)
539 }
540 if m.ContextWindow != tc.context || m.MaxOutput != 128000 || !m.Reasoning {
541 t.Fatalf("%s/%s limits: %+v", tc.provider, tc.id, m)
542 }
543 if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite {
544 t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m)
545 }
546 }
547}
548
549// TestOpenCodeGoCatalog pins the Grok 4.5 and Kimi K3 entries added to
550// the OpenCode Go provider. Both are listed in the vendor's current Go
551// model lineup (https://opencode.ai/docs/go) and were missing from the
552// baked-in catalog.
553func TestOpenCodeGoCatalog(t *testing.T) {
554 cases := []struct {
555 id string
556 context int
557 maxOut int
558 priceIn float64
559 priceOut float64
560 cacheRead float64
561 }{
562 {"grok-4.5", 500000, 500000, 2, 6, 0.3},
563 {"kimi-k3", 1048576, 131072, 3, 15, 0.3},
564 }
565 for _, tc := range cases {
566 m, err := FindModel("opencode-go", tc.id)
567 if err != nil {
568 t.Fatal(err)
569 }
570 if m.ContextWindow != tc.context || m.MaxOutput != tc.maxOut || !m.Reasoning {
571 t.Fatalf("opencode-go/%s limits: %+v", tc.id, m)
572 }
573 if m.PriceInput != tc.priceIn || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead {
574 t.Fatalf("opencode-go/%s prices: %+v", tc.id, m)
575 }
576 if m.BaseURL != "https://opencode.ai/zen/go/v1" {
577 t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL)
578 }
579 }
580}
581
582// TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for
583// discovered models that don't advertise an output cap (MaxOutput == 0).
584func TestOpenAIOmitsZeroMaxTokens(t *testing.T) {
585 SetLiveModels([]Model{
586 {Provider: "openrouter", ID: "vendor/no-cap", DisplayName: "No Cap"},
587 {Provider: "openrouter", ID: "vendor/reason-no-cap", DisplayName: "Reason No Cap", Reasoning: true},
588 {Provider: "openrouter", ID: "vendor/capped", DisplayName: "Capped", MaxOutput: 4096},
589 })
590 defer SetLiveModels(nil)
591
592 c := NewOpenAI("x", "").(*openaiClient)
593
594 got, err := c.buildRequest(Request{Model: "vendor/no-cap"})
595 if err != nil {
596 t.Fatal(err)
597 }
598 if got.MaxTokens != nil {
599 t.Errorf("expected max_tokens omitted, got %d", *got.MaxTokens)
600 }
601
602 got, err = c.buildRequest(Request{Model: "vendor/reason-no-cap"})
603 if err != nil {
604 t.Fatal(err)
605 }
606 if got.MaxCompletionTok != nil {
607 t.Errorf("expected max_completion_tokens omitted, got %d", *got.MaxCompletionTok)
608 }
609
610 got, err = c.buildRequest(Request{Model: "vendor/capped"})
611 if err != nil {
612 t.Fatal(err)
613 }
614 if got.MaxTokens == nil || *got.MaxTokens != 4096 {
615 t.Errorf("expected max_tokens 4096, got %v", got.MaxTokens)
616 }
617}