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