provider_registry_test.go

  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}