1package provider
2
3// Azure OpenAI client.
4//
5// Strategy: reuse the existing openaiClient (Chat Completions wire format)
6// but with three Azure-specific adjustments handled at construction time:
7//
8// - URL shape: `{base}/openai/deployments/{model}/chat/completions?api-version={v}`
9// This is wired by rewriting the model id into a path-prefix so the
10// downstream code that appends `/v1/chat/completions` lands on the
11// right endpoint. We achieve this by routing requests through a small
12// RoundTripper that rewrites the outgoing URL before the request hits
13// the network. The `openaiClient` itself is untouched.
14// - Auth: `api-key: <key>` header instead of `Authorization: Bearer ...`.
15// Sent via openaiClient.headers; the standard Authorization header
16// remains but Azure ignores it.
17// - API version: appended as `?api-version=...` query string by the
18// same RoundTripper.
19//
20// We deliberately use Chat Completions (not the newer Responses API) so we
21// don't have to duplicate the entire openai-responses wire client. The
22// agent loop only needs streamed text + tool calls, both of which work
23// fine on Azure's Chat Completions deployment.
24
25import (
26 "fmt"
27 "net/http"
28 "net/url"
29 "os"
30 "strings"
31)
32
33const (
34 defaultAzureAPIVersion = "2024-10-21"
35 defaultAzureResponsesAPIVersion = "v1"
36)
37
38// azureRewriteTransport rewrites every outgoing OpenAI Chat Completions
39// URL into the Azure deployment-scoped shape:
40//
41// /openai/v1/chat/completions ->
42// /openai/deployments/{model}/chat/completions?api-version={v}
43//
44// The model id is read from the JSON body of the POST so we don't need to
45// thread it through the client at construction time. This keeps the
46// openaiClient request-builder untouched.
47type azureRewriteTransport struct {
48 inner http.RoundTripper
49 apiVersion string
50}
51
52func (t *azureRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
53 if strings.Contains(req.URL.Path, "/chat/completions") {
54 // Extract model id from the JSON body. The body is a *bytes.Reader
55 // (set up by openaiClient.Stream) so we can peek without consuming
56 // the request — Go's http.NewRequestWithContext gives us
57 // GetBody() automatically for *bytes.Reader inputs.
58 modelID := ""
59 if req.GetBody != nil {
60 if body, err := req.GetBody(); err == nil {
61 buf := make([]byte, 8192)
62 n, _ := body.Read(buf)
63 _ = body.Close()
64 // Cheap manual scan: look for `"model":"<id>"`.
65 s := string(buf[:n])
66 if i := strings.Index(s, `"model":"`); i >= 0 {
67 rest := s[i+len(`"model":"`):]
68 if j := strings.Index(rest, `"`); j >= 0 {
69 modelID = rest[:j]
70 }
71 }
72 }
73 }
74 if modelID == "" {
75 return nil, fmt.Errorf("azure: could not extract model id from request body")
76 }
77 // Strip whatever prefix the openaiClient picked and replace with
78 // Azure's deployment path. Both `/openai/v1/chat/completions` and
79 // plain `/chat/completions` are normalised.
80 newPath := "/openai/deployments/" + url.PathEscape(modelID) + "/chat/completions"
81 // Anchor at the host root; the openaiClient may have used a
82 // base URL with no path or with `/openai/v1`.
83 req.URL.Path = newPath
84 q := req.URL.Query()
85 q.Set("api-version", t.apiVersion)
86 req.URL.RawQuery = q.Encode()
87 }
88 return t.inner.RoundTrip(req)
89}
90
91// NewAzureOpenAI returns an Azure OpenAI client.
92//
93// baseURL examples (any is fine; we normalise trailing slashes):
94//
95// https://my-resource.openai.azure.com
96// https://my-resource.openai.azure.com/openai/v1
97//
98// apiKey is the Azure resource key. The api-version comes from
99// AZURE_OPENAI_API_VERSION env var (default "2024-10-21").
100func NewAzureOpenAI(apiKey, baseURL string) Client {
101 if baseURL == "" {
102 // Try env, then resource-name expansion.
103 baseURL = os.Getenv("AZURE_OPENAI_BASE_URL")
104 if baseURL == "" {
105 if rn := os.Getenv("AZURE_OPENAI_RESOURCE_NAME"); rn != "" {
106 baseURL = "https://" + rn + ".openai.azure.com"
107 }
108 }
109 }
110 if baseURL == "" {
111 return &unimplementedClient{
112 name: "azure-openai-responses",
113 hint: "set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME (or pass --base-url)",
114 }
115 }
116 apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION")
117 if apiVersion == "" {
118 apiVersion = defaultAzureAPIVersion
119 }
120 // Strip a trailing /openai/v1 (and /openai) since the rewrite transport
121 // builds the full path itself.
122 clean := strings.TrimRight(baseURL, "/")
123 clean = strings.TrimSuffix(clean, "/openai/v1")
124 clean = strings.TrimSuffix(clean, "/openai")
125 httpClient := &http.Client{
126 Transport: &azureRewriteTransport{
127 inner: http.DefaultTransport,
128 apiVersion: apiVersion,
129 },
130 Timeout: 0,
131 }
132 return &openaiClient{
133 apiKey: apiKey,
134 baseURL: clean,
135 name: "azure-openai-responses",
136 headers: map[string]string{"api-key": apiKey},
137 http: httpClient,
138 }
139}
140
141// azureResponsesTransport applies Azure API-key authentication and API
142// versioning to requests using the OpenAI Responses wire format.
143type azureResponsesTransport struct {
144 inner http.RoundTripper
145 apiKey string
146 apiVersion string
147}
148
149func (t *azureResponsesTransport) RoundTrip(req *http.Request) (*http.Response, error) {
150 clone := req.Clone(req.Context())
151 clone.Header.Del("authorization")
152 clone.Header.Del("chatgpt-account-id")
153 clone.Header.Del("openai-beta")
154 clone.Header.Del("originator")
155 clone.Header.Set("api-key", t.apiKey)
156 q := clone.URL.Query()
157 q.Set("api-version", t.apiVersion)
158 clone.URL.RawQuery = q.Encode()
159 return t.inner.RoundTrip(clone)
160}
161
162// NewAzureOpenAIResponses creates an Azure OpenAI Responses API client.
163// The configured model id is used as the deployment name unless overridden
164// through AZURE_OPENAI_DEPLOYMENT_NAME_MAP (model=deployment pairs).
165func NewAzureOpenAIResponses(apiKey, baseURL string) Client {
166 if baseURL == "" {
167 baseURL = os.Getenv("AZURE_OPENAI_BASE_URL")
168 if baseURL == "" {
169 if resource := os.Getenv("AZURE_OPENAI_RESOURCE_NAME"); resource != "" {
170 baseURL = "https://" + resource + ".openai.azure.com"
171 }
172 }
173 }
174 if baseURL == "" {
175 return &unimplementedClient{
176 name: "azure-openai-responses",
177 hint: "set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME (or pass --base-url)",
178 }
179 }
180
181 endpoint := strings.TrimRight(baseURL, "/")
182 switch {
183 case strings.HasSuffix(endpoint, "/openai/v1/responses"):
184 case strings.HasSuffix(endpoint, "/openai/v1"):
185 endpoint += "/responses"
186 case strings.HasSuffix(endpoint, "/openai"):
187 endpoint += "/v1/responses"
188 default:
189 endpoint += "/openai/v1/responses"
190 }
191 apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION")
192 if apiVersion == "" {
193 apiVersion = defaultAzureResponsesAPIVersion
194 }
195 httpClient := &http.Client{
196 Transport: &azureResponsesTransport{
197 inner: http.DefaultTransport,
198 apiKey: apiKey,
199 apiVersion: apiVersion,
200 },
201 Timeout: 0,
202 }
203 inner := &codexClient{
204 token: apiKey,
205 baseURL: endpoint,
206 errorLabel: "azure openai",
207 providerName: "azure-openai-responses",
208 modelName: azureDeploymentName,
209 disableCLIRouting: true,
210 http: httpClient,
211 }
212 return &renamedClient{inner: inner, name: "azure-openai-responses"}
213}
214
215func azureDeploymentName(model string) string {
216 for _, pair := range strings.Split(os.Getenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP"), ",") {
217 parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
218 if len(parts) == 2 && strings.TrimSpace(parts[0]) == model {
219 if deployment := strings.TrimSpace(parts[1]); deployment != "" {
220 return deployment
221 }
222 }
223 }
224 return model
225}