openrouter_test.go

  1package providertests
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/http"
  7	"os"
  8	"strconv"
  9	"strings"
 10	"testing"
 11
 12	"github.com/charmbracelet/fantasy/ai"
 13	"github.com/charmbracelet/fantasy/openrouter"
 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	id := 0
101	generateIDFunc := func() string {
102		id += 1
103		return fmt.Sprintf("test-%d", id)
104	}
105
106	t.Run("unique tool call ids", func(t *testing.T) {
107		r := newRecorder(t)
108
109		provider := openrouter.New(
110			openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
111			openrouter.WithHTTPClient(&http.Client{Transport: r}),
112			openrouter.WithLanguageUniqueToolCallIds(),
113			openrouter.WithLanguageModelGenerateIDFunc(generateIDFunc),
114		)
115		languageModel, err := provider.LanguageModel("moonshotai/kimi-k2-0905")
116		require.NoError(t, err, "failed to build language model")
117
118		agent := ai.NewAgent(
119			languageModel,
120			ai.WithSystemPrompt("You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS."),
121			ai.WithTools(addTool),
122			ai.WithTools(multiplyTool),
123		)
124		result, err := agent.Generate(t.Context(), ai.AgentCall{
125			Prompt:          "Add and multiply the number 2 and 3",
126			MaxOutputTokens: ai.IntOption(4000),
127		})
128		require.NoError(t, err, "failed to generate")
129		checkResult(t, result)
130	})
131	t.Run("stream unique tool call ids", func(t *testing.T) {
132		r := newRecorder(t)
133
134		provider := openrouter.New(
135			openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
136			openrouter.WithHTTPClient(&http.Client{Transport: r}),
137			openrouter.WithLanguageUniqueToolCallIds(),
138			openrouter.WithLanguageModelGenerateIDFunc(generateIDFunc),
139		)
140		languageModel, err := provider.LanguageModel("moonshotai/kimi-k2-0905")
141		require.NoError(t, err, "failed to build language model")
142
143		agent := ai.NewAgent(
144			languageModel,
145			ai.WithSystemPrompt("You are a helpful assistant. Always use both add and multiply at the same time."),
146			ai.WithTools(addTool),
147			ai.WithTools(multiplyTool),
148		)
149		result, err := agent.Stream(t.Context(), ai.AgentStreamCall{
150			Prompt:          "Add and multiply the number 2 and 3",
151			MaxOutputTokens: ai.IntOption(4000),
152		})
153		require.NoError(t, err, "failed to generate")
154		checkResult(t, result)
155	})
156}
157
158func builderOpenRouterKimiK2(r *recorder.Recorder) (ai.LanguageModel, error) {
159	provider := openrouter.New(
160		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
161		openrouter.WithHTTPClient(&http.Client{Transport: r}),
162	)
163	return provider.LanguageModel("moonshotai/kimi-k2-0905")
164}
165
166func builderOpenRouterGrokCodeFast1(r *recorder.Recorder) (ai.LanguageModel, error) {
167	provider := openrouter.New(
168		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
169		openrouter.WithHTTPClient(&http.Client{Transport: r}),
170	)
171	return provider.LanguageModel("x-ai/grok-code-fast-1")
172}
173
174func builderOpenRouterGrok4FastFree(r *recorder.Recorder) (ai.LanguageModel, error) {
175	provider := openrouter.New(
176		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
177		openrouter.WithHTTPClient(&http.Client{Transport: r}),
178	)
179	return provider.LanguageModel("x-ai/grok-4-fast:free")
180}
181
182func builderOpenRouterGemini25Flash(r *recorder.Recorder) (ai.LanguageModel, error) {
183	provider := openrouter.New(
184		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
185		openrouter.WithHTTPClient(&http.Client{Transport: r}),
186	)
187	return provider.LanguageModel("google/gemini-2.5-flash")
188}
189
190func builderOpenRouterGemini20Flash(r *recorder.Recorder) (ai.LanguageModel, error) {
191	provider := openrouter.New(
192		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
193		openrouter.WithHTTPClient(&http.Client{Transport: r}),
194	)
195	return provider.LanguageModel("google/gemini-2.0-flash-001")
196}
197
198func builderOpenRouterDeepseekV31Free(r *recorder.Recorder) (ai.LanguageModel, error) {
199	provider := openrouter.New(
200		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
201		openrouter.WithHTTPClient(&http.Client{Transport: r}),
202	)
203	return provider.LanguageModel("deepseek/deepseek-chat-v3.1:free")
204}
205
206func builderOpenRouterClaudeSonnet4(r *recorder.Recorder) (ai.LanguageModel, error) {
207	provider := openrouter.New(
208		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
209		openrouter.WithHTTPClient(&http.Client{Transport: r}),
210	)
211	return provider.LanguageModel("anthropic/claude-sonnet-4")
212}
213
214func builderOpenRouterGPT5(r *recorder.Recorder) (ai.LanguageModel, error) {
215	provider := openrouter.New(
216		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
217		openrouter.WithHTTPClient(&http.Client{Transport: r}),
218	)
219	return provider.LanguageModel("openai/gpt-5")
220}
221
222func builderOpenRouterGLM45(r *recorder.Recorder) (ai.LanguageModel, error) {
223	provider := openrouter.New(
224		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
225		openrouter.WithHTTPClient(&http.Client{Transport: r}),
226	)
227	return provider.LanguageModel("z-ai/glm-4.5")
228}
229
230func builderOpenRouterQwen3Instruct(r *recorder.Recorder) (ai.LanguageModel, error) {
231	provider := openrouter.New(
232		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
233		openrouter.WithHTTPClient(&http.Client{Transport: r}),
234	)
235	return provider.LanguageModel("qwen/qwen3-235b-a22b-2507")
236}