1package providertests
2
3import (
4 "encoding/json"
5 "testing"
6
7 "charm.land/fantasy"
8 "charm.land/fantasy/providers/anthropic"
9 "charm.land/fantasy/providers/google"
10 "charm.land/fantasy/providers/openai"
11 "charm.land/fantasy/providers/openaicompat"
12 "charm.land/fantasy/providers/openrouter"
13 "github.com/stretchr/testify/require"
14)
15
16func TestProviderRegistry_Serialization_OpenAIOptions(t *testing.T) {
17 msg := fantasy.Message{
18 Role: fantasy.MessageRoleUser,
19 Content: []fantasy.MessagePart{
20 fantasy.TextPart{Text: "hi"},
21 },
22 ProviderOptions: fantasy.ProviderOptions{
23 openai.Name: &openai.ProviderOptions{User: fantasy.Opt("tester")},
24 },
25 }
26
27 data, err := json.Marshal(msg)
28 require.NoError(t, err)
29
30 var raw struct {
31 ProviderOptions map[string]map[string]any `json:"provider_options"`
32 }
33 require.NoError(t, json.Unmarshal(data, &raw))
34
35 po, ok := raw.ProviderOptions[openai.Name]
36 require.True(t, ok)
37 require.Equal(t, openai.TypeProviderOptions, po["type"]) // no magic strings
38 // ensure inner data has the field we set
39 inner, ok := po["data"].(map[string]any)
40 require.True(t, ok)
41 require.Equal(t, "tester", inner["user"])
42
43 var decoded fantasy.Message
44 require.NoError(t, json.Unmarshal(data, &decoded))
45
46 got, ok := decoded.ProviderOptions[openai.Name]
47 require.True(t, ok)
48 opt, ok := got.(*openai.ProviderOptions)
49 require.True(t, ok)
50 require.NotNil(t, opt.User)
51 require.Equal(t, "tester", *opt.User)
52}
53
54func TestProviderRegistry_Serialization_OpenAIResponses(t *testing.T) {
55 // Use ResponsesProviderOptions in provider options
56 msg := fantasy.Message{
57 Role: fantasy.MessageRoleUser,
58 Content: []fantasy.MessagePart{
59 fantasy.TextPart{Text: "hello"},
60 },
61 ProviderOptions: fantasy.ProviderOptions{
62 openai.Name: &openai.ResponsesProviderOptions{
63 PromptCacheKey: fantasy.Opt("cache-key-1"),
64 ParallelToolCalls: fantasy.Opt(true),
65 },
66 },
67 }
68
69 data, err := json.Marshal(msg)
70 require.NoError(t, err)
71
72 // JSON should include the typed wrapper with constant TypeResponsesProviderOptions
73 var raw struct {
74 ProviderOptions map[string]map[string]any `json:"provider_options"`
75 }
76 require.NoError(t, json.Unmarshal(data, &raw))
77
78 po := raw.ProviderOptions[openai.Name]
79 require.Equal(t, openai.TypeResponsesProviderOptions, po["type"]) // no magic strings
80 inner, ok := po["data"].(map[string]any)
81 require.True(t, ok)
82 require.Equal(t, "cache-key-1", inner["prompt_cache_key"])
83 require.Equal(t, true, inner["parallel_tool_calls"])
84
85 // Unmarshal back and assert concrete type
86 var decoded fantasy.Message
87 require.NoError(t, json.Unmarshal(data, &decoded))
88 got := decoded.ProviderOptions[openai.Name]
89 reqOpts, ok := got.(*openai.ResponsesProviderOptions)
90 require.True(t, ok)
91 require.NotNil(t, reqOpts.PromptCacheKey)
92 require.Equal(t, "cache-key-1", *reqOpts.PromptCacheKey)
93 require.NotNil(t, reqOpts.ParallelToolCalls)
94 require.Equal(t, true, *reqOpts.ParallelToolCalls)
95}
96
97func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *testing.T) {
98 resp := fantasy.Response{
99 Content: []fantasy.Content{
100 fantasy.TextContent{
101 Text: "",
102 ProviderMetadata: fantasy.ProviderMetadata{
103 openai.Name: &openai.ResponsesReasoningMetadata{
104 ItemID: "item-123",
105 Summary: []string{"part1", "part2"},
106 },
107 },
108 },
109 },
110 }
111
112 data, err := json.Marshal(resp)
113 require.NoError(t, err)
114
115 // Ensure the provider metadata is wrapped with type using constant
116 var raw struct {
117 Content []struct {
118 Type string `json:"type"`
119 Data map[string]any `json:"data"`
120 } `json:"content"`
121 }
122 require.NoError(t, json.Unmarshal(data, &raw))
123 require.Greater(t, len(raw.Content), 0)
124 tc := raw.Content[0]
125 pm, ok := tc.Data["provider_metadata"].(map[string]any)
126 require.True(t, ok)
127 om, ok := pm[openai.Name].(map[string]any)
128 require.True(t, ok)
129 require.Equal(t, openai.TypeResponsesReasoningMetadata, om["type"]) // no magic strings
130 inner, ok := om["data"].(map[string]any)
131 require.True(t, ok)
132 require.Equal(t, "item-123", inner["item_id"])
133
134 // Unmarshal back
135 var decoded fantasy.Response
136 require.NoError(t, json.Unmarshal(data, &decoded))
137 pmDecoded := decoded.Content[0].(fantasy.TextContent).ProviderMetadata
138 val, ok := pmDecoded[openai.Name]
139 require.True(t, ok)
140 meta, ok := val.(*openai.ResponsesReasoningMetadata)
141 require.True(t, ok)
142 require.Equal(t, "item-123", meta.ItemID)
143 require.Equal(t, []string{"part1", "part2"}, meta.Summary)
144}
145
146func TestProviderRegistry_Serialization_AnthropicOptions(t *testing.T) {
147 sendReasoning := true
148 msg := fantasy.Message{
149 Role: fantasy.MessageRoleUser,
150 Content: []fantasy.MessagePart{
151 fantasy.TextPart{Text: "test message"},
152 },
153 ProviderOptions: fantasy.ProviderOptions{
154 anthropic.Name: &anthropic.ProviderOptions{
155 SendReasoning: &sendReasoning,
156 },
157 },
158 }
159
160 data, err := json.Marshal(msg)
161 require.NoError(t, err)
162
163 var decoded fantasy.Message
164 require.NoError(t, json.Unmarshal(data, &decoded))
165
166 got, ok := decoded.ProviderOptions[anthropic.Name]
167 require.True(t, ok)
168 opt, ok := got.(*anthropic.ProviderOptions)
169 require.True(t, ok)
170 require.NotNil(t, opt.SendReasoning)
171 require.Equal(t, true, *opt.SendReasoning)
172}
173
174func TestProviderRegistry_Serialization_GoogleOptions(t *testing.T) {
175 msg := fantasy.Message{
176 Role: fantasy.MessageRoleUser,
177 Content: []fantasy.MessagePart{
178 fantasy.TextPart{Text: "test message"},
179 },
180 ProviderOptions: fantasy.ProviderOptions{
181 google.Name: &google.ProviderOptions{
182 CachedContent: "cached-123",
183 Threshold: "BLOCK_ONLY_HIGH",
184 ThinkingConfig: &google.ThinkingConfig{
185 ThinkingLevel: fantasy.Opt(google.ThinkingLevelHigh),
186 },
187 },
188 },
189 }
190
191 data, err := json.Marshal(msg)
192 require.NoError(t, err)
193
194 var decoded fantasy.Message
195 require.NoError(t, json.Unmarshal(data, &decoded))
196
197 got, ok := decoded.ProviderOptions[google.Name]
198 require.True(t, ok)
199 opt, ok := got.(*google.ProviderOptions)
200 require.True(t, ok)
201 require.Equal(t, "cached-123", opt.CachedContent)
202 require.Equal(t, "BLOCK_ONLY_HIGH", opt.Threshold)
203 require.NotNil(t, opt.ThinkingConfig)
204 require.NotNil(t, opt.ThinkingConfig.ThinkingLevel)
205 require.Equal(t, google.ThinkingLevelHigh, *opt.ThinkingConfig.ThinkingLevel)
206}
207
208func TestProviderRegistry_Serialization_OpenRouterOptions(t *testing.T) {
209 includeUsage := true
210 msg := fantasy.Message{
211 Role: fantasy.MessageRoleUser,
212 Content: []fantasy.MessagePart{
213 fantasy.TextPart{Text: "test message"},
214 },
215 ProviderOptions: fantasy.ProviderOptions{
216 openrouter.Name: &openrouter.ProviderOptions{
217 IncludeUsage: &includeUsage,
218 User: fantasy.Opt("test-user"),
219 },
220 },
221 }
222
223 data, err := json.Marshal(msg)
224 require.NoError(t, err)
225
226 var decoded fantasy.Message
227 require.NoError(t, json.Unmarshal(data, &decoded))
228
229 got, ok := decoded.ProviderOptions[openrouter.Name]
230 require.True(t, ok)
231 opt, ok := got.(*openrouter.ProviderOptions)
232 require.True(t, ok)
233 require.NotNil(t, opt.IncludeUsage)
234 require.Equal(t, true, *opt.IncludeUsage)
235 require.NotNil(t, opt.User)
236 require.Equal(t, "test-user", *opt.User)
237}
238
239func TestProviderRegistry_Serialization_OpenAICompatOptions(t *testing.T) {
240 effort := openai.ReasoningEffortHigh
241 msg := fantasy.Message{
242 Role: fantasy.MessageRoleUser,
243 Content: []fantasy.MessagePart{
244 fantasy.TextPart{Text: "test message"},
245 },
246 ProviderOptions: fantasy.ProviderOptions{
247 openaicompat.Name: &openaicompat.ProviderOptions{
248 User: fantasy.Opt("test-user"),
249 ReasoningEffort: &effort,
250 },
251 },
252 }
253
254 data, err := json.Marshal(msg)
255 require.NoError(t, err)
256
257 var decoded fantasy.Message
258 require.NoError(t, json.Unmarshal(data, &decoded))
259
260 got, ok := decoded.ProviderOptions[openaicompat.Name]
261 require.True(t, ok)
262 opt, ok := got.(*openaicompat.ProviderOptions)
263 require.True(t, ok)
264 require.NotNil(t, opt.User)
265 require.Equal(t, "test-user", *opt.User)
266 require.NotNil(t, opt.ReasoningEffort)
267 require.Equal(t, openai.ReasoningEffortHigh, *opt.ReasoningEffort)
268}
269
270func TestProviderRegistry_MultiProvider(t *testing.T) {
271 // Test with multiple providers in one message
272 sendReasoning := true
273 msg := fantasy.Message{
274 Role: fantasy.MessageRoleUser,
275 Content: []fantasy.MessagePart{
276 fantasy.TextPart{Text: "test"},
277 },
278 ProviderOptions: fantasy.ProviderOptions{
279 openai.Name: &openai.ProviderOptions{User: fantasy.Opt("user1")},
280 anthropic.Name: &anthropic.ProviderOptions{
281 SendReasoning: &sendReasoning,
282 },
283 },
284 }
285
286 data, err := json.Marshal(msg)
287 require.NoError(t, err)
288
289 var decoded fantasy.Message
290 require.NoError(t, json.Unmarshal(data, &decoded))
291
292 // Check OpenAI options
293 openaiOpt, ok := decoded.ProviderOptions[openai.Name]
294 require.True(t, ok)
295 openaiData, ok := openaiOpt.(*openai.ProviderOptions)
296 require.True(t, ok)
297 require.Equal(t, "user1", *openaiData.User)
298
299 // Check Anthropic options
300 anthropicOpt, ok := decoded.ProviderOptions[anthropic.Name]
301 require.True(t, ok)
302 anthropicData, ok := anthropicOpt.(*anthropic.ProviderOptions)
303 require.True(t, ok)
304 require.Equal(t, true, *anthropicData.SendReasoning)
305}
306
307func TestProviderRegistry_ErrorHandling(t *testing.T) {
308 t.Run("unknown provider type", func(t *testing.T) {
309 invalidJSON := `{
310 "role": "user",
311 "content": [{"type": "text", "data": {"text": "hi"}}],
312 "provider_options": {
313 "unknown": {
314 "type": "unknown.provider.type",
315 "data": {}
316 }
317 }
318 }`
319
320 var msg fantasy.Message
321 err := json.Unmarshal([]byte(invalidJSON), &msg)
322 require.Error(t, err)
323 require.Contains(t, err.Error(), "unknown provider data type")
324 })
325
326 t.Run("malformed provider data", func(t *testing.T) {
327 invalidJSON := `{
328 "role": "user",
329 "content": [{"type": "text", "data": {"text": "hi"}}],
330 "provider_options": {
331 "openai": "not-an-object"
332 }
333 }`
334
335 var msg fantasy.Message
336 err := json.Unmarshal([]byte(invalidJSON), &msg)
337 require.Error(t, err)
338 })
339}
340
341func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
342 // Verify all expected provider types are registered
343 // We test that unmarshaling with proper type IDs doesn't fail with "unknown provider data type"
344 tests := []struct {
345 name string
346 providerName string
347 data fantasy.ProviderOptionsData
348 }{
349 {"OpenAI Options", openai.Name, &openai.ProviderOptions{}},
350 {"OpenAI File Options", openai.Name, &openai.ProviderFileOptions{}},
351 {"OpenAI Metadata", openai.Name, &openai.ProviderMetadata{}},
352 {"OpenAI Responses Options", openai.Name, &openai.ResponsesProviderOptions{}},
353 {"Anthropic Options", anthropic.Name, &anthropic.ProviderOptions{}},
354 {"Google Options", google.Name, &google.ProviderOptions{}},
355 {"OpenRouter Options", openrouter.Name, &openrouter.ProviderOptions{}},
356 {"OpenAICompat Options", openaicompat.Name, &openaicompat.ProviderOptions{}},
357 }
358
359 for _, tc := range tests {
360 t.Run(tc.name, func(t *testing.T) {
361 // Create a message with the provider options
362 msg := fantasy.Message{
363 Role: fantasy.MessageRoleUser,
364 Content: []fantasy.MessagePart{
365 fantasy.TextPart{Text: "test"},
366 },
367 ProviderOptions: fantasy.ProviderOptions{
368 tc.providerName: tc.data,
369 },
370 }
371
372 // Marshal and unmarshal
373 data, err := json.Marshal(msg)
374 require.NoError(t, err)
375
376 var decoded fantasy.Message
377 err = json.Unmarshal(data, &decoded)
378 require.NoError(t, err)
379
380 // Verify the provider options exist
381 _, ok := decoded.ProviderOptions[tc.providerName]
382 require.True(t, ok, "Provider options should be present after round-trip")
383 })
384 }
385
386 // Test metadata types separately as they go in different field
387 metadataTests := []struct {
388 name string
389 providerName string
390 data fantasy.ProviderOptionsData
391 }{
392 {"OpenAI Responses Reasoning Metadata", openai.Name, &openai.ResponsesReasoningMetadata{}},
393 {"Anthropic Reasoning Metadata", anthropic.Name, &anthropic.ReasoningOptionMetadata{}},
394 {"Google Reasoning Metadata", google.Name, &google.ReasoningMetadata{}},
395 {"OpenRouter Metadata", openrouter.Name, &openrouter.ProviderMetadata{}},
396 }
397
398 for _, tc := range metadataTests {
399 t.Run(tc.name, func(t *testing.T) {
400 // Create a response with provider metadata
401 resp := fantasy.Response{
402 Content: []fantasy.Content{
403 fantasy.TextContent{
404 Text: "test",
405 ProviderMetadata: fantasy.ProviderMetadata{
406 tc.providerName: tc.data,
407 },
408 },
409 },
410 }
411
412 // Marshal and unmarshal
413 data, err := json.Marshal(resp)
414 require.NoError(t, err)
415
416 var decoded fantasy.Response
417 err = json.Unmarshal(data, &decoded)
418 require.NoError(t, err)
419
420 // Verify the provider metadata exists
421 textContent, ok := decoded.Content[0].(fantasy.TextContent)
422 require.True(t, ok)
423 _, ok = textContent.ProviderMetadata[tc.providerName]
424 require.True(t, ok, "Provider metadata should be present after round-trip")
425 })
426 }
427}