azure_openai.go RawHistoryFolder
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 defaultAzureAPIVersion = "2024-10-21"
34
35// azureRewriteTransport rewrites every outgoing OpenAI Chat Completions
36// URL into the Azure deployment-scoped shape:
37//
38// /openai/v1/chat/completions ->
39// /openai/deployments/{model}/chat/completions?api-version={v}
40//
41// The model id is read from the JSON body of the POST so we don't need to
42// thread it through the client at construction time. This keeps the
43// openaiClient request-builder untouched.
44type azureRewriteTransport struct {
45 inner http.RoundTripper
46 apiVersion string
47}
48
49func (t *azureRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
50 if strings.Contains(req.URL.Path, "/chat/completions") {
51 // Extract model id from the JSON body. The body is a *bytes.Reader
52 // (set up by openaiClient.Stream) so we can peek without consuming
53 // the request — Go's http.NewRequestWithContext gives us
54 // GetBody() automatically for *bytes.Reader inputs.
55 modelID := ""
56 if req.GetBody != nil {
57 if body, err := req.GetBody(); err == nil {
58 buf := make([]byte, 8192)
59 n, _ := body.Read(buf)
60 _ = body.Close()
61 // Cheap manual scan: look for `"model":"<id>"`.
62 s := string(buf[:n])
63 if i := strings.Index(s, `"model":"`); i >= 0 {
64 rest := s[i+len(`"model":"`):]
65 if j := strings.Index(rest, `"`); j >= 0 {
66 modelID = rest[:j]
67 }
68 }
69 }
70 }
71 if modelID == "" {
72 return nil, fmt.Errorf("azure: could not extract model id from request body")
73 }
74 // Strip whatever prefix the openaiClient picked and replace with
75 // Azure's deployment path. Both `/openai/v1/chat/completions` and
76 // plain `/chat/completions` are normalised.
77 newPath := "/openai/deployments/" + url.PathEscape(modelID) + "/chat/completions"
78 // Anchor at the host root; the openaiClient may have used a
79 // base URL with no path or with `/openai/v1`.
80 req.URL.Path = newPath
81 q := req.URL.Query()
82 q.Set("api-version", t.apiVersion)
83 req.URL.RawQuery = q.Encode()
84 }
85 return t.inner.RoundTrip(req)
86}
87
88// NewAzureOpenAI returns an Azure OpenAI client.
89//
90// baseURL examples (any is fine; we normalise trailing slashes):
91//
92// https://my-resource.openai.azure.com
93// https://my-resource.openai.azure.com/openai/v1
94//
95// apiKey is the Azure resource key. The api-version comes from
96// AZURE_OPENAI_API_VERSION env var (default "2024-10-21").
97func NewAzureOpenAI(apiKey, baseURL string) Client {
98 if baseURL == "" {
99 // Try env, then resource-name expansion.
100 baseURL = os.Getenv("AZURE_OPENAI_BASE_URL")
101 if baseURL == "" {
102 if rn := os.Getenv("AZURE_OPENAI_RESOURCE_NAME"); rn != "" {
103 baseURL = "https://" + rn + ".openai.azure.com"
104 }
105 }
106 }
107 if baseURL == "" {
108 return &unimplementedClient{
109 name: "azure-openai-responses",
110 hint: "set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME (or pass --base-url)",
111 }
112 }
113 apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION")
114 if apiVersion == "" {
115 apiVersion = defaultAzureAPIVersion
116 }
117 // Strip a trailing /openai/v1 (and /openai) since the rewrite transport
118 // builds the full path itself.
119 clean := strings.TrimRight(baseURL, "/")
120 clean = strings.TrimSuffix(clean, "/openai/v1")
121 clean = strings.TrimSuffix(clean, "/openai")
122 httpClient := &http.Client{
123 Transport: &azureRewriteTransport{
124 inner: http.DefaultTransport,
125 apiVersion: apiVersion,
126 },
127 Timeout: 0,
128 }
129 return &openaiClient{
130 apiKey: apiKey,
131 baseURL: clean,
132 name: "azure-openai-responses",
133 headers: map[string]string{"api-key": apiKey},
134 http: httpClient,
135 }
136}