Skip to content

Commit 8a6f094

Browse files
committed
WIP feat: add azure provider
1 parent fcdf057 commit 8a6f094

24 files changed

Lines changed: 1817 additions & 9 deletions

azure/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Azure
2+
3+
* Go to https://portal.azure.com/ and log in (or sign up if you haven't already)
4+
* Access https://ai.azure.com/
5+
* Create a project / resource (or use an existing one)
6+
* Copy and API key

azure/azure.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package azure
2+
3+
import (
4+
"github.com/charmbracelet/fantasy/ai"
5+
"github.com/charmbracelet/fantasy/openai"
6+
"github.com/charmbracelet/fantasy/openaicompat"
7+
"github.com/openai/openai-go/v2/azure"
8+
"github.com/openai/openai-go/v2/option"
9+
)
10+
11+
type options struct {
12+
baseURL string
13+
apiKey string
14+
apiVersion string
15+
16+
openaiOptions []openai.Option
17+
}
18+
19+
const (
20+
Name = "azure"
21+
defaultAPIVersion = "2025-01-01-preview"
22+
)
23+
24+
type Option = func(*options)
25+
26+
func New(opts ...Option) ai.Provider {
27+
o := options{
28+
apiVersion: defaultAPIVersion,
29+
}
30+
for _, opt := range opts {
31+
opt(&o)
32+
}
33+
return openai.New(
34+
append(
35+
o.openaiOptions,
36+
openai.WithName(Name),
37+
openai.WithSDKOptions(
38+
azure.WithEndpoint(o.baseURL, o.apiVersion),
39+
azure.WithAPIKey(o.apiKey),
40+
),
41+
openai.WithLanguageModelOptions(
42+
openai.WithLanguageModelPrepareCallFunc(openaicompat.LanguagePrepareModelCall),
43+
openai.WithLanguageModelUsageFunc(languageModelUsageFunc),
44+
),
45+
)...,
46+
)
47+
}
48+
49+
func WithBaseURL(baseURL string) Option {
50+
return func(o *options) {
51+
o.baseURL = baseURL
52+
}
53+
}
54+
55+
func WithAPIKey(apiKey string) Option {
56+
return func(o *options) {
57+
o.apiKey = apiKey
58+
}
59+
}
60+
61+
func WithAPIVersion(version string) Option {
62+
return func(o *options) {
63+
o.apiVersion = version
64+
}
65+
}
66+
67+
func WithHTTPClient(client option.HTTPClient) Option {
68+
return func(o *options) {
69+
o.openaiOptions = append(o.openaiOptions, openai.WithHTTPClient(client))
70+
}
71+
}

azure/language_model_hooks.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package azure
2+
3+
import (
4+
"github.com/charmbracelet/fantasy/ai"
5+
"github.com/openai/openai-go/v2"
6+
)
7+
8+
func languageModelUsageFunc(choice openai.ChatCompletion) (ai.Usage, ai.ProviderOptionsData) {
9+
return ai.Usage{
10+
InputTokens: choice.Usage.PromptTokens,
11+
OutputTokens: choice.Usage.CompletionTokens,
12+
TotalTokens: choice.Usage.TotalTokens,
13+
ReasoningTokens: choice.Usage.CompletionTokensDetails.ReasoningTokens,
14+
CacheCreationTokens: 0,
15+
CacheReadTokens: choice.Usage.PromptTokensDetails.CachedTokens,
16+
}, nil
17+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ require (
2020
cloud.google.com/go v0.116.0 // indirect
2121
cloud.google.com/go/auth v0.9.3 // indirect
2222
cloud.google.com/go/compute/metadata v0.5.0 // indirect
23+
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
24+
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
2325
github.com/davecgh/go-spew v1.1.1 // indirect
2426
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
2527
github.com/google/go-cmp v0.6.0 // indirect

go.sum

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
55
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
66
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
77
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
8+
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
9+
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
10+
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
11+
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
12+
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
13+
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
14+
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
15+
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
816
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
917
github.com/anthropics/anthropic-sdk-go v1.10.0 h1:jDKQTfC0miIEj21eMmPrNSLKTNdNa3nHZOhd4wZz1cI=
1018
github.com/anthropics/anthropic-sdk-go v1.10.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
@@ -24,6 +32,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
2432
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
2533
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
2634
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
35+
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
36+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
2737
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
2838
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
2939
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@@ -57,11 +67,21 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
5767
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
5868
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
5969
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
70+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
71+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
72+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
73+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
74+
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
75+
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
6076
github.com/openai/openai-go/v2 v2.3.0 h1:y9U+V1tlHjvvb/5XIswuySqnG5EnKBFAbMxgBvTHXvg=
6177
github.com/openai/openai-go/v2 v2.3.0/go.mod h1:sIUkR+Cu/PMUVkSKhkk742PRURkQOCFhiwJ7eRSBqmk=
78+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
79+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
6280
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6381
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6482
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
83+
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
84+
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
6585
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
6686
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
6787
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -149,8 +169,9 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
149169
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
150170
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
151171
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
152-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
153172
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
173+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
174+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
154175
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117 h1:fbE/sTnBb9UNfE8cJsOzrYYPqVWVHb7jWH4SI1W//cM=
155176
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117/go.mod h1:YuVT9NPq7t3oT2WpUemB0DbNL7djIjgajZycxoDLnqs=
156177
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

openai/openai.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type options struct {
2626
name string
2727
headers map[string]string
2828
client option.HTTPClient
29+
sdkOptions []option.RequestOption
2930
languageModelOptions []LanguageModelOption
3031
}
3132

@@ -95,6 +96,12 @@ func WithHTTPClient(client option.HTTPClient) Option {
9596
}
9697
}
9798

99+
func WithSDKOptions(opts ...option.RequestOption) Option {
100+
return func(o *options) {
101+
o.sdkOptions = append(o.sdkOptions, opts...)
102+
}
103+
}
104+
98105
func WithLanguageModelOptions(opts ...LanguageModelOption) Option {
99106
return func(o *options) {
100107
o.languageModelOptions = append(o.languageModelOptions, opts...)
@@ -103,26 +110,29 @@ func WithLanguageModelOptions(opts ...LanguageModelOption) Option {
103110

104111
// LanguageModel implements ai.Provider.
105112
func (o *provider) LanguageModel(modelID string) (ai.LanguageModel, error) {
106-
openaiClientOptions := []option.RequestOption{}
113+
options := make([]option.RequestOption, 0, 5+len(o.options.headers)+len(o.options.sdkOptions))
114+
107115
if o.options.apiKey != "" {
108-
openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(o.options.apiKey))
116+
options = append(options, option.WithAPIKey(o.options.apiKey))
109117
}
110118
if o.options.baseURL != "" {
111-
openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(o.options.baseURL))
119+
options = append(options, option.WithBaseURL(o.options.baseURL))
112120
}
113121

114122
for key, value := range o.options.headers {
115-
openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
123+
options = append(options, option.WithHeader(key, value))
116124
}
117125

118126
if o.options.client != nil {
119-
openaiClientOptions = append(openaiClientOptions, option.WithHTTPClient(o.options.client))
127+
options = append(options, option.WithHTTPClient(o.options.client))
120128
}
121129

130+
options = append(options, o.options.sdkOptions...)
131+
122132
return newLanguageModel(
123133
modelID,
124134
o.options.name,
125-
openai.NewClient(openaiClientOptions...),
135+
openai.NewClient(options...),
126136
o.options.languageModelOptions...,
127137
), nil
128138
}

openaicompat/language_model_hooks.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
const reasoningStartedCtx = "reasoning_started"
1515

16-
func languagePrepareModelCall(model ai.LanguageModel, params *openaisdk.ChatCompletionNewParams, call ai.Call) ([]ai.CallWarning, error) {
16+
func LanguagePrepareModelCall(model ai.LanguageModel, params *openaisdk.ChatCompletionNewParams, call ai.Call) ([]ai.CallWarning, error) {
1717
providerOptions := &ProviderOptions{}
1818
if v, ok := call.ProviderOptions[Name]; ok {
1919
providerOptions, ok = v.(*ProviderOptions)

openaicompat/openaicompat.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func New(opts ...Option) ai.Provider {
2424
openai.WithName(Name),
2525
},
2626
languageModelOptions: []openai.LanguageModelOption{
27-
openai.WithLanguageModelPrepareCallFunc(languagePrepareModelCall),
27+
openai.WithLanguageModelPrepareCallFunc(LanguagePrepareModelCall),
2828
openai.WithLanguageModelStreamExtraFunc(languageModelStreamExtra),
2929
openai.WithLanguageModelExtraContentFunc(languageModelExtraContent),
3030
},

providertests/.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
FANTASY_ANTHROPIC_API_KEY=
2+
FANTASY_AZURE_API_KEY=
3+
FANTASY_AZURE_BASE_URL=
24
FANTASY_GEMINI_API_KEY=
35
FANTASY_GROQ_API_KEY=
46
FANTASY_OPENAI_API_KEY=

providertests/azure_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package providertests
2+
3+
import (
4+
"cmp"
5+
"net/http"
6+
"os"
7+
"testing"
8+
9+
"github.com/charmbracelet/fantasy/ai"
10+
"github.com/charmbracelet/fantasy/azure"
11+
"github.com/charmbracelet/fantasy/openai"
12+
"github.com/stretchr/testify/require"
13+
"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
14+
)
15+
16+
func TestAzureCommon(t *testing.T) {
17+
testCommon(t, []builderPair{
18+
{"azure-o4-mini", builderAzureO4Mini, nil},
19+
{"azure-gpt-5-mini", builderGpt5Mini, nil},
20+
})
21+
}
22+
23+
func TestAzureThinking(t *testing.T) {
24+
opts := ai.ProviderOptions{
25+
openai.Name: &openai.ProviderOptions{
26+
ReasoningEffort: openai.ReasoningEffortOption(openai.ReasoningEffortLow),
27+
},
28+
}
29+
testThinking(t, []builderPair{
30+
{"azure-gpt-5-mini", builderGpt5Mini, opts},
31+
}, testAzureThinking)
32+
}
33+
34+
func testAzureThinking(t *testing.T, result *ai.AgentResult) {
35+
require.Greater(t, result.Response.Usage.ReasoningTokens, int64(0), "expected reasoning content, got none")
36+
}
37+
38+
func builderAzureO4Mini(r *recorder.Recorder) (ai.LanguageModel, error) {
39+
provider := azure.New(
40+
azure.WithBaseURL(cmp.Or(os.Getenv("FANTASY_AZURE_BASE_URL"), "(missing)")),
41+
azure.WithAPIKey(cmp.Or(os.Getenv("FANTASY_AZURE_API_KEY"), "(missing)")),
42+
azure.WithHTTPClient(&http.Client{Transport: r}),
43+
)
44+
return provider.LanguageModel("o4-mini")
45+
}
46+
47+
func builderGpt5Mini(r *recorder.Recorder) (ai.LanguageModel, error) {
48+
provider := azure.New(
49+
azure.WithBaseURL(cmp.Or(os.Getenv("FANTASY_AZURE_BASE_URL"), "(missing)")),
50+
azure.WithAPIKey(cmp.Or(os.Getenv("FANTASY_AZURE_API_KEY"), "(missing)")),
51+
azure.WithHTTPClient(&http.Client{Transport: r}),
52+
)
53+
return provider.LanguageModel("gpt-5-mini")
54+
}

0 commit comments

Comments
 (0)