v0.4.0 a622146

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 (
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close