1package provider
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "strings"
8 "testing"
9)
10
11func TestSSEParse(t *testing.T) {
12 r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n")
13 ch := make(chan sseEvent, 4)
14 go readSSE(r, ch)
15
16 e := <-ch
17 if e.Event != "foo" || e.Data != `{"a":1}` {
18 t.Fatalf("event 1: %+v", e)
19 }
20 e = <-ch
21 if e.Event != "" || e.Data != "hello\nworld" {
22 t.Fatalf("event 2: %+v", e)
23 }
24 if _, ok := <-ch; ok {
25 t.Fatalf("channel not closed")
26 }
27}
28
29func TestModelCatalog(t *testing.T) {
30 if len(Catalog) == 0 {
31 t.Fatal("empty catalog")
32 }
33 if _, err := FindModel("anthropic", "claude-sonnet-4-5"); err != nil {
34 t.Fatal(err)
35 }
36 if _, err := FindModel("openai", "gpt-5"); err != nil {
37 t.Fatal(err)
38 }
39 if _, err := FindModel("", "nope"); err == nil {
40 t.Fatal("expected error")
41 }
42}
43
44func TestComputeCost(t *testing.T) {
45 m, _ := FindModel("anthropic", "claude-sonnet-4-5")
46 cost := ComputeCost(m, Usage{InputTokens: 1_000_000, OutputTokens: 1_000_000})
47 want := m.PriceInput + m.PriceOutput
48 if cost != want {
49 t.Fatalf("cost=%v want=%v", cost, want)
50 }
51}
52
53func TestAnthropicErrorStatus(t *testing.T) {
54 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55 w.WriteHeader(http.StatusUnauthorized)
56 _, _ = w.Write([]byte(`{"error":{"type":"auth","message":"bad key"}}`))
57 }))
58 defer srv.Close()
59
60 c := NewAnthropic("x", srv.URL)
61 _, err := c.Stream(context.Background(), Request{Model: "claude-sonnet-4-5"})
62 if err == nil || !strings.Contains(err.Error(), "401") {
63 t.Fatalf("want 401 err, got %v", err)
64 }
65}
66
67func TestOpenAIErrorStatus(t *testing.T) {
68 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69 w.WriteHeader(http.StatusBadRequest)
70 _, _ = w.Write([]byte(`{"error":{"message":"nope"}}`))
71 }))
72 defer srv.Close()
73
74 c := NewOpenAI("x", srv.URL)
75 _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
76 if err == nil || !strings.Contains(err.Error(), "400") {
77 t.Fatalf("want 400 err, got %v", err)
78 }
79}
80
81func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) {
82 c := NewAnthropic("x", "").(*anthropicClient)
83 wire, err := c.buildRequest(Request{
84 Model: "claude-sonnet-4-5",
85 Messages: []Message{
86 {Role: RoleUser, Content: []Content{TextBlock{Text: "make an image"}}},
87 {Role: RoleAssistant, Content: []Content{
88 TextBlock{Text: "done"},
89 ImageBlock{MimeType: "image/png", Data: []byte("png")},
90 TextBlock{Text: "Saved image: `zot-gemini-image-x.png`"},
91 }},
92 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
93 },
94 })
95 if err != nil {
96 t.Fatal(err)
97 }
98 if len(wire.Messages) != 3 {
99 t.Fatalf("messages=%d", len(wire.Messages))
100 }
101 assistant := wire.Messages[1]
102 if assistant.Role != "assistant" {
103 t.Fatalf("role=%q", assistant.Role)
104 }
105 if len(assistant.Content) != 2 {
106 t.Fatalf("assistant content=%+v", assistant.Content)
107 }
108 for _, b := range assistant.Content {
109 if _, ok := b.(anthImageBlock); ok {
110 t.Fatalf("assistant image block was not stripped: %+v", assistant.Content)
111 }
112 }
113}
114
115func TestAnthropicStreamHappyPath(t *testing.T) {
116 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117 w.Header().Set("content-type", "text/event-stream")
118 w.WriteHeader(http.StatusOK)
119 fl, _ := w.(http.Flusher)
120 write := func(s string) {
121 _, _ = w.Write([]byte(s))
122 if fl != nil {
123 fl.Flush()
124 }
125 }
126 write("event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n")
127 write("event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")
128 write("event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n")
129 write("event: content_block_stop\ndata: {\"index\":0}\n\n")
130 write("event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n")
131 write("event: message_stop\ndata: {}\n\n")
132 }))
133 defer srv.Close()
134
135 c := NewAnthropic("x", srv.URL)
136 evs, err := c.Stream(context.Background(), Request{Model: "claude-sonnet-4-5"})
137 if err != nil {
138 t.Fatal(err)
139 }
140 var gotText string
141 var done EventDone
142 for ev := range evs {
143 switch e := ev.(type) {
144 case EventTextDelta:
145 gotText += e.Delta
146 case EventDone:
147 done = e
148 }
149 }
150 if gotText != "hi" {
151 t.Fatalf("text=%q", gotText)
152 }
153 if done.Stop != StopEnd {
154 t.Fatalf("stop=%v", done.Stop)
155 }
156}
157
158func TestOpenAIBuildRequestSkipsReasoningOnlyAssistantMessages(t *testing.T) {
159 c := NewKimi("token", "").(*openaiClient)
160 wire, err := c.buildRequest(Request{
161 Model: "kimi-for-coding",
162 Messages: []Message{
163 {Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}},
164 {Role: RoleAssistant, Content: []Content{ReasoningBlock{Summary: "thinking only"}}},
165 {Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
166 },
167 })
168 if err != nil {
169 t.Fatal(err)
170 }
171 for i, msg := range wire.Messages {
172 if msg.Role == "assistant" && msg.Content == nil && len(msg.ToolCalls) == 0 {
173 t.Fatalf("message %d is empty assistant: %+v", i, msg)
174 }
175 }
176 if got := len(wire.Messages); got != 2 {
177 t.Fatalf("messages=%d want 2 after skipping reasoning-only assistant", got)
178 }
179}
180
181func TestOpenAIStreamHappyPath(t *testing.T) {
182 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183 w.Header().Set("content-type", "text/event-stream")
184 w.WriteHeader(http.StatusOK)
185 fl, _ := w.(http.Flusher)
186 write := func(s string) {
187 _, _ = w.Write([]byte(s))
188 if fl != nil {
189 fl.Flush()
190 }
191 }
192 write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n")
193 write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n")
194 write("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":2}}\n\n")
195 write("data: [DONE]\n\n")
196 }))
197 defer srv.Close()
198
199 c := NewOpenAI("x", srv.URL)
200 evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
201 if err != nil {
202 t.Fatal(err)
203 }
204 var gotText string
205 var done EventDone
206 for ev := range evs {
207 switch e := ev.(type) {
208 case EventTextDelta:
209 gotText += e.Delta
210 case EventDone:
211 done = e
212 }
213 }
214 if gotText != "hello" {
215 t.Fatalf("text=%q", gotText)
216 }
217 if done.Stop != StopEnd {
218 t.Fatalf("stop=%v", done.Stop)
219 }
220}