1package providertests
  2
  3import (
  4	"context"
  5	"net/http"
  6	"os"
  7	"strconv"
  8	"strings"
  9	"testing"
 10
 11	"github.com/charmbracelet/fantasy/ai"
 12	"github.com/charmbracelet/fantasy/openrouter"
 13	"github.com/google/uuid"
 14	"github.com/stretchr/testify/require"
 15	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
 16)
 17
 18func TestOpenRouterCommon(t *testing.T) {
 19	testCommon(t, []builderPair{
 20		{"kimi-k2", builderOpenRouterKimiK2, nil},
 21		{"grok-code-fast-1", builderOpenRouterGrokCodeFast1, nil},
 22		{"claude-sonnet-4", builderOpenRouterClaudeSonnet4, nil},
 23		{"grok-4-fast-free", builderOpenRouterGrok4FastFree, nil},
 24		{"gemini-2.5-flash", builderOpenRouterGemini25Flash, nil},
 25		{"gemini-2.0-flash", builderOpenRouterGemini20Flash, nil},
 26		{"deepseek-chat-v3.1-free", builderOpenRouterDeepseekV31Free, nil},
 27		{"qwen3-235b-a22b-2507", builderOpenRouterQwen3Instruct, nil},
 28		{"gpt-5", builderOpenRouterGPT5, nil},
 29		{"glm-4.5", builderOpenRouterGLM45, nil},
 30	})
 31	opts := ai.ProviderOptions{
 32		openrouter.Name: &openrouter.ProviderOptions{
 33			Reasoning: &openrouter.ReasoningOptions{
 34				Effort: openrouter.ReasoningEffortOption(openrouter.ReasoningEffortMedium),
 35			},
 36		},
 37	}
 38	testThinking(t, []builderPair{
 39		{"gpt-5", builderOpenRouterGPT5, opts},
 40		{"glm-4.5", builderOpenRouterGLM45, opts},
 41	}, testOpenrouterThinking)
 42}
 43
 44func testOpenrouterThinking(t *testing.T, result *ai.AgentResult) {
 45	reasoningContentCount := 0
 46	for _, step := range result.Steps {
 47		for _, msg := range step.Messages {
 48			for _, content := range msg.Content {
 49				if content.GetType() == ai.ContentTypeReasoning {
 50					reasoningContentCount += 1
 51				}
 52			}
 53		}
 54	}
 55	require.Greater(t, reasoningContentCount, 0)
 56}
 57
 58func TestWithUniqueToolCallIDs(t *testing.T) {
 59	type CalculatorInput struct {
 60		A int `json:"a" description:"first number"`
 61		B int `json:"b" description:"second number"`
 62	}
 63
 64	addTool := ai.NewAgentTool(
 65		"add",
 66		"Add two numbers",
 67		func(ctx context.Context, input CalculatorInput, _ ai.ToolCall) (ai.ToolResponse, error) {
 68			result := input.A + input.B
 69			return ai.NewTextResponse(strings.TrimSpace(strconv.Itoa(result))), nil
 70		},
 71	)
 72	multiplyTool := ai.NewAgentTool(
 73		"multiply",
 74		"Multiply two numbers",
 75		func(ctx context.Context, input CalculatorInput, _ ai.ToolCall) (ai.ToolResponse, error) {
 76			result := input.A * input.B
 77			return ai.NewTextResponse(strings.TrimSpace(strconv.Itoa(result))), nil
 78		},
 79	)
 80	checkResult := func(t *testing.T, result *ai.AgentResult) {
 81		require.Len(t, result.Steps, 2)
 82
 83		var toolCalls []ai.ToolCallContent
 84		for _, content := range result.Steps[0].Content {
 85			if content.GetType() == ai.ContentTypeToolCall {
 86				toolCalls = append(toolCalls, content.(ai.ToolCallContent))
 87			}
 88		}
 89		for _, tc := range toolCalls {
 90			require.False(t, tc.Invalid)
 91			require.Contains(t, tc.ToolCallID, "test-")
 92		}
 93		require.Len(t, toolCalls, 2)
 94
 95		finalText := result.Response.Content.Text()
 96		require.Contains(t, finalText, "5", "expected response to contain '5', got: %q", finalText)
 97		require.Contains(t, finalText, "6", "expected response to contain '6', got: %q", finalText)
 98	}
 99
100	generateIDFunc := func() string {
101		return "test-" + uuid.NewString()
102	}
103
104	t.Run("unique tool call ids", func(t *testing.T) {
105		r := newRecorder(t)
106
107		provider := openrouter.New(
108			openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
109			openrouter.WithHTTPClient(&http.Client{Transport: r}),
110			openrouter.WithLanguageUniqueToolCallIds(),
111			openrouter.WithLanguageModelGenerateIDFunc(generateIDFunc),
112		)
113		languageModel, err := provider.LanguageModel("moonshotai/kimi-k2-0905")
114		require.NoError(t, err, "failed to build language model")
115
116		agent := ai.NewAgent(
117			languageModel,
118			ai.WithSystemPrompt("You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS."),
119			ai.WithTools(addTool),
120			ai.WithTools(multiplyTool),
121		)
122		result, err := agent.Generate(t.Context(), ai.AgentCall{
123			Prompt:          "Add and multiply the number 2 and 3",
124			MaxOutputTokens: ai.IntOption(4000),
125		})
126		require.NoError(t, err, "failed to generate")
127		checkResult(t, result)
128	})
129	t.Run("stream unique tool call ids", func(t *testing.T) {
130		r := newRecorder(t)
131
132		provider := openrouter.New(
133			openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
134			openrouter.WithHTTPClient(&http.Client{Transport: r}),
135			openrouter.WithLanguageUniqueToolCallIds(),
136			openrouter.WithLanguageModelGenerateIDFunc(generateIDFunc),
137		)
138		languageModel, err := provider.LanguageModel("moonshotai/kimi-k2-0905")
139		require.NoError(t, err, "failed to build language model")
140
141		agent := ai.NewAgent(
142			languageModel,
143			ai.WithSystemPrompt("You are a helpful assistant. Always use both add and multiply at the same time."),
144			ai.WithTools(addTool),
145			ai.WithTools(multiplyTool),
146		)
147		result, err := agent.Stream(t.Context(), ai.AgentStreamCall{
148			Prompt:          "Add and multiply the number 2 and 3",
149			MaxOutputTokens: ai.IntOption(4000),
150		})
151		require.NoError(t, err, "failed to generate")
152		checkResult(t, result)
153	})
154}
155
156func builderOpenRouterKimiK2(r *recorder.Recorder) (ai.LanguageModel, error) {
157	provider := openrouter.New(
158		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
159		openrouter.WithHTTPClient(&http.Client{Transport: r}),
160	)
161	return provider.LanguageModel("moonshotai/kimi-k2-0905")
162}
163
164func builderOpenRouterGrokCodeFast1(r *recorder.Recorder) (ai.LanguageModel, error) {
165	provider := openrouter.New(
166		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
167		openrouter.WithHTTPClient(&http.Client{Transport: r}),
168	)
169	return provider.LanguageModel("x-ai/grok-code-fast-1")
170}
171
172func builderOpenRouterGrok4FastFree(r *recorder.Recorder) (ai.LanguageModel, error) {
173	provider := openrouter.New(
174		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
175		openrouter.WithHTTPClient(&http.Client{Transport: r}),
176	)
177	return provider.LanguageModel("x-ai/grok-4-fast:free")
178}
179
180func builderOpenRouterGemini25Flash(r *recorder.Recorder) (ai.LanguageModel, error) {
181	provider := openrouter.New(
182		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
183		openrouter.WithHTTPClient(&http.Client{Transport: r}),
184	)
185	return provider.LanguageModel("google/gemini-2.5-flash")
186}
187
188func builderOpenRouterGemini20Flash(r *recorder.Recorder) (ai.LanguageModel, error) {
189	provider := openrouter.New(
190		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
191		openrouter.WithHTTPClient(&http.Client{Transport: r}),
192	)
193	return provider.LanguageModel("google/gemini-2.0-flash-001")
194}
195
196func builderOpenRouterDeepseekV31Free(r *recorder.Recorder) (ai.LanguageModel, error) {
197	provider := openrouter.New(
198		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
199		openrouter.WithHTTPClient(&http.Client{Transport: r}),
200	)
201	return provider.LanguageModel("deepseek/deepseek-chat-v3.1:free")
202}
203
204func builderOpenRouterClaudeSonnet4(r *recorder.Recorder) (ai.LanguageModel, error) {
205	provider := openrouter.New(
206		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
207		openrouter.WithHTTPClient(&http.Client{Transport: r}),
208	)
209	return provider.LanguageModel("anthropic/claude-sonnet-4")
210}
211
212func builderOpenRouterGPT5(r *recorder.Recorder) (ai.LanguageModel, error) {
213	provider := openrouter.New(
214		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
215		openrouter.WithHTTPClient(&http.Client{Transport: r}),
216	)
217	return provider.LanguageModel("openai/gpt-5")
218}
219
220func builderOpenRouterGLM45(r *recorder.Recorder) (ai.LanguageModel, error) {
221	provider := openrouter.New(
222		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
223		openrouter.WithHTTPClient(&http.Client{Transport: r}),
224	)
225	return provider.LanguageModel("z-ai/glm-4.5")
226}
227
228func builderOpenRouterQwen3Instruct(r *recorder.Recorder) (ai.LanguageModel, error) {
229	provider := openrouter.New(
230		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
231		openrouter.WithHTTPClient(&http.Client{Transport: r}),
232	)
233	return provider.LanguageModel("qwen/qwen3-235b-a22b-2507")
234}