Skip to content

Commit 4048173

Browse files
committed
test: setup go-vcr to start recording real providers interactions
1 parent 7f1366b commit 4048173

9 files changed

Lines changed: 238 additions & 0 deletions

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
providertests/testdata/* linguist-generated=true

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ require (
77
github.com/charmbracelet/x/json v0.2.0
88
github.com/go-viper/mapstructure/v2 v2.4.0
99
github.com/google/uuid v1.6.0
10+
github.com/joho/godotenv v1.5.1
1011
github.com/openai/openai-go/v2 v2.3.0
1112
github.com/stretchr/testify v1.11.1
13+
gopkg.in/dnaeon/go-vcr.v4 v4.0.5
1214
)
1315

1416
require (
1517
github.com/davecgh/go-spew v1.1.1 // indirect
18+
github.com/goccy/go-yaml v1.18.0 // indirect
1619
github.com/pmezard/go-difflib v1.0.0 // indirect
1720
github.com/tidwall/gjson v1.18.0 // indirect
1821
github.com/tidwall/match v1.1.1 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
66
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
77
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
88
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
9+
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
10+
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
911
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
1012
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
13+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
14+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
1115
github.com/openai/openai-go/v2 v2.3.0 h1:y9U+V1tlHjvvb/5XIswuySqnG5EnKBFAbMxgBvTHXvg=
1216
github.com/openai/openai-go/v2 v2.3.0/go.mod h1:sIUkR+Cu/PMUVkSKhkk742PRURkQOCFhiwJ7eRSBqmk=
1317
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -26,5 +30,7 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
2630
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
2731
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
2832
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
33+
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
34+
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
2935
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
3036
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

providertests/.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ANTHROPIC_API_KEY=
2+
OPENAI_API_KEY=

providertests/builders_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package providertests
2+
3+
import (
4+
"net/http"
5+
"os"
6+
7+
"github.com/charmbracelet/ai/ai"
8+
"github.com/charmbracelet/ai/anthropic"
9+
"github.com/charmbracelet/ai/openai"
10+
"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
11+
)
12+
13+
type builderFunc func(r *recorder.Recorder) (ai.LanguageModel, error)
14+
15+
type builderPair struct {
16+
name string
17+
builder builderFunc
18+
}
19+
20+
var languageModelBuilders = []builderPair{
21+
{"openai-gpt-4o", builderOpenaiGpt4o},
22+
{"anthropic-claude-sonnet", builderAnthropicClaudeSonnet4},
23+
}
24+
25+
func builderOpenaiGpt4o(r *recorder.Recorder) (ai.LanguageModel, error) {
26+
provider := openai.New(
27+
openai.WithAPIKey(os.Getenv("OPENAI_API_KEY")),
28+
openai.WithHTTPClient(&http.Client{Transport: r}),
29+
)
30+
model, err := provider.LanguageModel("gpt-4o")
31+
if err != nil {
32+
return nil, err
33+
}
34+
return model, nil
35+
}
36+
37+
func builderAnthropicClaudeSonnet4(r *recorder.Recorder) (ai.LanguageModel, error) {
38+
provider := anthropic.New(
39+
anthropic.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
40+
anthropic.WithHTTPClient(&http.Client{Transport: r}),
41+
)
42+
model, err := provider.LanguageModel("claude-sonnet-4-20250514")
43+
if err != nil {
44+
return nil, err
45+
}
46+
return model, nil
47+
}

providertests/provider_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package providertests
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/charmbracelet/ai/ai"
8+
_ "github.com/joho/godotenv/autoload"
9+
)
10+
11+
func TestSimple(t *testing.T) {
12+
for _, pair := range languageModelBuilders {
13+
t.Run(pair.name, func(t *testing.T) {
14+
r := newRecorder(t)
15+
16+
languageModel, err := pair.builder(r)
17+
if err != nil {
18+
t.Fatalf("failed to build language model: %v", err)
19+
}
20+
21+
agent := ai.NewAgent(
22+
languageModel,
23+
ai.WithSystemPrompt("You are a helpful assistant"),
24+
)
25+
result, err := agent.Generate(t.Context(), ai.AgentCall{
26+
Prompt: "Say hi in Portuguese",
27+
})
28+
if err != nil {
29+
t.Fatalf("failed to generate: %v", err)
30+
}
31+
32+
want := "Olá"
33+
got := result.Response.Content.Text()
34+
if !strings.Contains(got, want) {
35+
t.Fatalf("unexpected response: got %q, want %q", got, want)
36+
}
37+
})
38+
}
39+
}

providertests/recorder_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package providertests
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"
12+
"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
13+
)
14+
15+
func newRecorder(t *testing.T) *recorder.Recorder {
16+
cassetteName := filepath.Join("testdata", t.Name())
17+
18+
r, err := recorder.New(
19+
cassetteName,
20+
recorder.WithMode(recorder.ModeRecordOnce),
21+
recorder.WithMatcher(customMatcher(t)),
22+
recorder.WithHook(hookRemoveHeaders, recorder.AfterCaptureHook),
23+
)
24+
if err != nil {
25+
t.Fatalf("recorder: failed to create recorder: %v", err)
26+
}
27+
28+
t.Cleanup(func() {
29+
if err := r.Stop(); err != nil {
30+
t.Errorf("recorder: failed to stop recorder: %v", err)
31+
}
32+
})
33+
34+
return r
35+
}
36+
37+
func customMatcher(t *testing.T) recorder.MatcherFunc {
38+
return func(r *http.Request, i cassette.Request) bool {
39+
if r.Body == nil || r.Body == http.NoBody {
40+
return cassette.DefaultMatcher(r, i)
41+
}
42+
43+
var reqBody []byte
44+
var err error
45+
reqBody, err = io.ReadAll(r.Body)
46+
if err != nil {
47+
t.Fatalf("recorder: failed to read request body")
48+
}
49+
r.Body.Close()
50+
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
51+
52+
return r.Method == i.Method && r.URL.String() == i.URL && string(reqBody) == i.Body
53+
}
54+
}
55+
56+
var headersToKeep = map[string]struct{}{
57+
"accept": {},
58+
"content-type": {},
59+
"user-agent": {},
60+
}
61+
62+
func hookRemoveHeaders(i *cassette.Interaction) error {
63+
for k := range i.Request.Headers {
64+
if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
65+
delete(i.Request.Headers, k)
66+
}
67+
}
68+
for k := range i.Response.Headers {
69+
if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
70+
delete(i.Response.Headers, k)
71+
}
72+
}
73+
return nil
74+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
version: 2
3+
interactions:
4+
- id: 0
5+
request:
6+
proto: HTTP/1.1
7+
proto_major: 1
8+
proto_minor: 1
9+
content_length: 205
10+
host: ""
11+
body: "{\"max_tokens\":4096,\"messages\":[{\"content\":[{\"text\":\"Say hi in Portuguese\",\"type\":\"text\"}],\"role\":\"user\"}],\"model\":\"claude-sonnet-4-20250514\",\"system\":[{\"text\":\"You are a helpful assistant\",\"type\":\"text\"}]}"
12+
headers:
13+
Accept:
14+
- application/json
15+
Content-Type:
16+
- application/json
17+
User-Agent:
18+
- Anthropic/Go 1.10.0
19+
url: https://api.anthropic.com/v1/messages
20+
method: POST
21+
response:
22+
proto: HTTP/2.0
23+
proto_major: 2
24+
proto_minor: 0
25+
content_length: -1
26+
uncompressed: true
27+
body: "{\"id\":\"msg_014AQFTJZeZ1KNGT5y9TSMSs\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-20250514\",\"content\":[{\"type\":\"text\",\"text\":\"Oi! or Olá!\\n\\nBoth are common ways to say \\\"hi\\\" in Portuguese. \\\"Oi\\\" is more casual and commonly used in Brazilian Portuguese, while \\\"Olá\\\" is a bit more formal and used in both Brazilian and European Portuguese.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":16,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":60,\"service_tier\":\"standard\"}}"
28+
headers:
29+
Content-Type:
30+
- application/json
31+
status: 200 OK
32+
code: 200
33+
duration: 2.412166125s
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
version: 2
3+
interactions:
4+
- id: 0
5+
request:
6+
proto: HTTP/1.1
7+
proto_major: 1
8+
proto_minor: 1
9+
content_length: 138
10+
host: ""
11+
body: "{\"messages\":[{\"content\":\"You are a helpful assistant\",\"role\":\"system\"},{\"content\":\"Say hi in Portuguese\",\"role\":\"user\"}],\"model\":\"gpt-4o\"}"
12+
headers:
13+
Accept:
14+
- application/json
15+
Content-Type:
16+
- application/json
17+
User-Agent:
18+
- OpenAI/Go 2.3.0
19+
url: https://api.openai.com/v1/chat/completions
20+
method: POST
21+
response:
22+
proto: HTTP/2.0
23+
proto_major: 2
24+
proto_minor: 0
25+
content_length: -1
26+
uncompressed: true
27+
body: "{\n \"id\": \"chatcmpl-CBolfurp0H2jFXSwJHVGbLWOYHhbM\",\n \"object\": \"chat.completion\",\n \"created\": 1756932795,\n \"model\": \"gpt-4o-2024-08-06\",\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": \"assistant\",\n \"content\": \"Olá! Como posso ajudar você hoje?\",\n \"refusal\": null,\n \"annotations\": []\n },\n \"logprobs\": null,\n \"finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\": 20,\n \"completion_tokens\": 8,\n \"total_tokens\": 28,\n \"prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\": {\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\": \"default\",\n \"system_fingerprint\": \"fp_f33640a400\"\n}\n"
28+
headers:
29+
Content-Type:
30+
- application/json
31+
status: 200 OK
32+
code: 200
33+
duration: 3.363218s

0 commit comments

Comments
 (0)