diff --git a/internal/gateway/azure_test.go b/internal/gateway/azure_test.go
new file mode 100644
index 00000000..a7a6373a
--- /dev/null
+++ b/internal/gateway/azure_test.go
@@ -0,0 +1,134 @@
+package gateway
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// azureUp plays an Azure OpenAI resource's v1 API: chat completions and
+// Responses under /openai/v1, a key in api-key and nothing else — a
+// Bearer is an Entra ID token there, and a key sent as one is refused.
+type azureUp struct {
+ mu sync.Mutex
+ calls []bedrockCall
+}
+
+func (a *azureUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ var m map[string]any
+ json.Unmarshal(body, &m)
+ model, _ := m["model"].(string)
+ a.mu.Lock()
+ a.calls = append(a.calls, bedrockCall{r.URL.RequestURI(), r.Header.Clone(), model, m})
+ a.mu.Unlock()
+ if r.Header.Get("api-key") != "azure-key" || r.Header.Get("Authorization") != "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ io.WriteString(w, `{"error":{"code":"401","message":"Access denied due to invalid subscription key or wrong API endpoint."}}`)
+ return
+ }
+ switch r.URL.Path {
+ case "/openai/v1/chat/completions":
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, sse(
+ `data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"from azure chat"}}]}`,
+ `data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}`,
+ `data: [DONE]`))
+ case "/openai/v1/responses":
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, sse(
+ `data: {"type":"response.created","response":{"id":"r1","status":"in_progress"}}`,
+ `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"m1","role":"assistant","content":[]}}`,
+ `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"m1","delta":"from azure responses"}`,
+ `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"m1","role":"assistant","content":[{"type":"output_text","text":"from azure responses"}]}}`,
+ `data: {"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}}`))
+ default:
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ io.WriteString(w, `{"error":{"code":"404","message":"Resource not found"}}`)
+ }
+}
+
+func (a *azureUp) last() bedrockCall {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if len(a.calls) == 0 {
+ return bedrockCall{}
+ }
+ return a.calls[len(a.calls)-1]
+}
+
+// The Azure OpenAI preset at a fake resource: Codex's Responses, a chat
+// client's completions and Claude Code's messages all reach the resource's
+// v1 API with the key in api-key and no Authorization, the model in the
+// body the deployment's name as the agent picked it, and a reply length
+// asked as max_completion_tokens, which its reasoning deployments want.
+// A deployment named for a GPT model goes to Responses, one named any
+// other way to chat completions.
+func TestAzureRoutes(t *testing.T) {
+ fresh(t)
+ up := &azureUp{}
+ srv := httptest.NewServer(up)
+ t.Cleanup(srv.Close)
+ p, err := provider.FromPreset(provider.AzurePreset)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.Key = "azure-key"
+ // A local fake isn't a resource's host, so it is kept as given; the
+ // endpoint field sets both, as here.
+ p.Chat = srv.URL + "/openai/v1"
+ p.Responses = p.Chat
+ p.Models = []string{"gpt-5-codex", "team-chat"}
+ if err := provider.Save(p); err != nil {
+ t.Fatal(err)
+ }
+ saved, err := provider.Find("azure")
+ if err != nil || saved.Responses != saved.Chat || !saved.IsAzure() {
+ t.Fatalf("saved: %v %+v", err, saved)
+ }
+
+ for _, tc := range []struct{ name, path, body, reply, upstream, model string }{
+ {"codex", "/v1/responses",
+ `{"model":"azure/gpt-5-codex","stream":true,"input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}]}`,
+ "from azure responses", "/openai/v1/responses", "gpt-5-codex"},
+ {"claude code on a gpt deployment", "/v1/messages",
+ `{"model":"azure/gpt-5-codex","max_tokens":20,"stream":true,"messages":[{"role":"user","content":"hi"}]}`,
+ "from azure responses", "/openai/v1/responses", "gpt-5-codex"},
+ {"chat client", "/v1/chat/completions",
+ `{"model":"azure/team-chat","max_tokens":20,"stream":true,"thinking":{"type":"enabled"},"messages":[{"role":"user","content":"hi"}]}`,
+ "from azure chat", "/openai/v1/chat/completions", "team-chat"},
+ {"claude code on another deployment", "/v1/messages",
+ `{"model":"azure/team-chat","max_tokens":20,"stream":true,"messages":[{"role":"user","content":"hi"}]}`,
+ "from azure chat", "/openai/v1/chat/completions", "team-chat"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ code, body := post(t, tc.path, tc.body)
+ if code != 200 || !strings.Contains(body, tc.reply) {
+ t.Fatalf("status %d: %s", code, body)
+ }
+ c := up.last()
+ if c.path != tc.upstream || c.model != tc.model {
+ t.Fatalf("upstream: %s %q", c.path, c.model)
+ }
+ if c.head.Get("api-key") != "azure-key" || c.head.Get("Authorization") != "" || c.head.Get("x-api-key") != "" {
+ t.Fatalf("auth headers: %v", c.head)
+ }
+ if strings.HasSuffix(c.path, "/chat/completions") {
+ if _, ok := c.body["max_tokens"]; ok || c.body["max_completion_tokens"] != float64(20) {
+ t.Fatalf("length asked as: %v", c.body)
+ }
+ if _, ok := c.body["thinking"]; ok {
+ t.Fatalf("thinking sent to Azure: %v", c.body)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
index 650574dc..55066b99 100644
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -1252,12 +1252,15 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider.
switch proto {
case provider.Chat:
body = developerAsSystem(body)
- if strings.HasSuffix(p.Host(), "openai.com") {
+ if strings.HasSuffix(p.Host(), "openai.com") || p.IsAzure() {
// Qwen's switch and Kimi Code's (thinking: {type: …}), which
- // OpenAI turns away as arguments it doesn't know
+ // OpenAI turns away as arguments it doesn't know, and Azure
+ // OpenAI as well
body = withoutFields(body, "enable_thinking", "thinking")
}
- if p.IsBedrock() {
+ if p.IsBedrock() || p.IsAzure() {
+ // Azure's reasoning deployments (o4-mini, gpt-5) turn
+ // max_tokens away as Bedrock's GPT models do
body = asCompletionTokens(body)
}
case provider.Anthropic:
@@ -1476,7 +1479,7 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to
r.GeminiCompat, req = want, &r
}
body := build(to, req, model, p.Host(), p.RejectsTemperature(model))
- if to == provider.Chat && p.IsBedrock() {
+ if to == provider.Chat && (p.IsBedrock() || p.IsAzure()) {
body = asCompletionTokens(body)
}
if to == provider.CodeAssist && p.Account != nil {
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index fa8ef10e..08d6cb24 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -3112,6 +3112,18 @@ function renderEditor(p, presetID) {
return ed;
}
+ // a vendor reached at the user's own resource (Azure OpenAI): no URL of
+ // the preset's, the one the resource is at is typed or pasted here, and
+ // magpie puts it on the API the preset speaks when it is saved
+ let endpoint = null;
+ if (pr?.endpoint) {
+ if (draft.chat === undefined) { draft.chat = p?.chat || ""; draft.responses = p?.responses || ""; }
+ endpoint = input(draft.chat || draft.responses || "", pr.endpoint, "url");
+ endpoint.classList.add("endpoint");
+ endpoint.oninput = () => { draft.chat = draft.responses = endpoint.value.trim(); refreshEndpoints(); };
+ ed.append(...field(t("Endpoint"), endpoint, pr.endpointHint ? t(pr.endpointHint) : ""));
+ }
+
const key = input(draft.key || "", p?.key.set ? t("{masked} · paste a new key to replace it", { masked: p.key.masked }) : t(pr?.noKey || p?.key.optional ? "optional for local servers" : "paste an API key"), "password");
key.oninput = () => { draft.key = key.value; };
key.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter" && isNew) save(); else if (e.key === "Escape") cancelEdit(); };
@@ -3353,6 +3365,7 @@ function renderEditor(p, presetID) {
else if (draft.clearBalanceToken) body.clearBalanceToken = true;
if (isNew && custom && !body.name) { name.focus(); return editorError(t("Give it a name"), "warn"); }
if (isNew && custom && !body.chat && !body.anthropic && !body.responses) { url.focus(); return editorError(t("A base URL is needed"), "warn"); }
+ if (endpoint && !body.chat && !body.responses) { endpoint.focus(); return editorError(t("Your resource's endpoint is needed"), "warn"); }
editorError("");
saveBtn.classList.add("busy");
providerAction("save", body, t(isNew ? "{name} added" : "{name} saved", { name: draft.name || draft.id }));
@@ -3360,7 +3373,7 @@ function renderEditor(p, presetID) {
saveBtn.onclick = save;
bar.append(cancel, saveBtn);
ed.append(bar);
- setTimeout(() => (isNew ? (custom || another ? name : key) : null)?.focus(), 0);
+ setTimeout(() => (isNew ? (custom || another ? name : endpoint || key) : null)?.focus(), 0);
return ed;
}
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index d21a6a60..f0a3229d 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -460,7 +460,7 @@ const I18N = {
"it is the agent's own sign-in": "这是 agent 自己的登录",
"it is a sign-in, not a key; add the subscription in magpie": "这是登录账号而不是密钥,请在 magpie 里添加订阅",
"magpie doesn't talk the Gemini protocol to relays yet": "magpie 暂不支持以 Gemini 协议连接中转",
- "magpie doesn't support Azure OpenAI yet": "magpie 暂不支持 Azure OpenAI",
+ "it has no Azure OpenAI endpoint": "没有 Azure OpenAI 终结点",
"it points at magpie itself": "它指向的就是 magpie 自己",
"it has no base URL": "没有 Base URL",
"it has no API key": "没有 API 密钥",
@@ -547,6 +547,10 @@ const I18N = {
"{name} added": "已添加 {name}",
"Give it a name": "先起个名字",
"A base URL is needed": "需要填写 Base URL",
+ "Endpoint": "终结点",
+ "Your resource's endpoint is needed": "需要填写你的资源终结点",
+ "your resource's endpoint and key": "你的资源终结点和密钥",
+ "Your resource's endpoint, from Keys and Endpoint in the Azure portal. magpie asks its v1 API; the model ids are your deployments' names.": "你的资源终结点,见 Azure 门户的「密钥和终结点」。magpie 调用它的 v1 API;模型 ID 就是你的部署名称。",
"Test": "测试",
"Send a tiny request through each endpoint": "向每个端点发送一个小请求",
"model {model}": "模型 {model}",
diff --git a/internal/gui/assets/icons/azure-color.svg b/internal/gui/assets/icons/azure-color.svg
new file mode 100644
index 00000000..b3df8655
--- /dev/null
+++ b/internal/gui/assets/icons/azure-color.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/internal/gui/tests/azure-endpoint.test.cjs b/internal/gui/tests/azure-endpoint.test.cjs
new file mode 100644
index 00000000..5ed80a09
--- /dev/null
+++ b/internal/gui/tests/azure-endpoint.test.cjs
@@ -0,0 +1,113 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// Azure OpenAI's editor asks for the resource's endpoint (no host magpie
+// knows serves it): a field above the key, focused first, with the portal's
+// shape as its placeholder and a hint on where to find it and that the
+// model ids are the deployments' names. Adding without one says so and
+// sends nothing; with one, the endpoint goes as both chat and Responses.
+// Nothing moves the page. English and Chinese; no backend, the API is
+// faked here.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+
+const presets = [
+ { id: "openai", name: "OpenAI", icon: "openai", kind: "vendor", chat: "https://api.openai.com/v1", added: false },
+ { id: "azure", name: "Azure OpenAI", icon: "azure-color", kind: "vendor", added: false, catalog: "azure, openai",
+ note: "your resource's endpoint and key", endpoint: "https://.openai.azure.com",
+ endpointHint: "Your resource's endpoint, from Keys and Endpoint in the Azure portal. magpie asks its v1 API; the model ids are your deployments' names." },
+];
+const providers = [{ id: "openrouter", name: "OpenRouter", icon: "openai", preset: "openrouter", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }];
+
+function server(lang, saves) {
+ return async (route) => {
+ const url = new URL(route.request().url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
+ if (url.pathname === "/api/providers") return json({ providers, presets, excluded: [], gateway: { running: true, window: true } });
+ if (url.pathname === "/api/provider/save") {
+ saves.push(route.request().postDataJSON());
+ return json({ providers, presets, excluded: [], gateway: { running: true, window: true } });
+ }
+ if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
+ if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
+ };
+}
+
+const L = {
+ en: { field: "Endpoint", hint: /Keys and Endpoint in the Azure portal.*deployments' names/, needed: "Your resource's endpoint is needed", add: "Add" },
+ zh: { field: "终结点", hint: /Azure 门户的「密钥和终结点」.*部署名称/, needed: "需要填写你的资源终结点", add: "添加" },
+};
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ test(engine + ": Azure OpenAI asks for its endpoint", async (t) => {
+ assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(() => browser.close());
+ for (const lang of ["en", "zh"]) {
+ await t.test(lang, async () => {
+ const w = L[lang];
+ const saves = [];
+ const page = await (await browser.newContext({ viewport: { width: 900, height: 700 } })).newPage();
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", server(lang, saves));
+ await page.goto("http://magpie.test/?view=providers");
+ await page.locator("#addProvider").click();
+ const sheet = page.locator("#addSheet");
+ await sheet.locator(".tile .n", { hasText: /^Azure OpenAI$/ }).click();
+ const ed = page.locator(".editor.new");
+ await ed.locator(".ehead b", { hasText: "Azure OpenAI" }).waitFor();
+
+ // the field, above the key, focused first; the OpenAI preset has none
+ const label = ed.locator("label", { hasText: new RegExp("^" + w.field + "$") });
+ assert.equal(await label.count(), 1);
+ const endpoint = ed.locator("input.endpoint");
+ assert.equal(await endpoint.getAttribute("placeholder"), "https://.openai.azure.com");
+ assert.equal(await endpoint.getAttribute("type"), "url");
+ assert.match(await endpoint.locator("xpath=following-sibling::div[contains(@class,'hint')]").textContent(), w.hint);
+ await page.waitForFunction(() => document.activeElement?.classList.contains("endpoint"));
+ const key = ed.locator("input[type=password]").first();
+ const [ey, ky] = [await endpoint.boundingBox(), await key.boundingBox()].map((b) => b.y);
+ assert(ey < ky, "the endpoint above the key");
+
+ // no endpoint: said, nothing sent, the page where it was
+ await key.fill("az-key");
+ const y = await page.evaluate(() => document.scrollingElement.scrollTop);
+ await ed.locator(".bar button.primary", { hasText: w.add }).click();
+ assert.equal(await ed.locator(".editor-error").textContent(), w.needed);
+ assert.equal(saves.length, 0);
+ assert.equal(await page.evaluate(() => document.activeElement?.classList.contains("endpoint")), true);
+ assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), y);
+
+ // with one: sent as both chat and Responses, with the key
+ await endpoint.fill("https://contoso.openai.azure.com/");
+ await ed.locator(".bar button.primary", { hasText: w.add }).click();
+ await page.waitForFunction(() => !document.querySelector(".editor.new"));
+ assert.equal(saves.length, 1);
+ const s = saves[0];
+ assert.equal(s.preset, "azure");
+ assert.equal(s.key, "az-key");
+ assert.equal(s.chat, "https://contoso.openai.azure.com/");
+ assert.equal(s.responses, "https://contoso.openai.azure.com/");
+
+ // another preset's editor has no endpoint field
+ await page.locator("#addProvider").click();
+ await sheet.locator(".tile .n", { hasText: /^OpenAI$/ }).click();
+ await ed.locator(".ehead b", { hasText: "OpenAI" }).waitFor();
+ assert.equal(await ed.locator("input.endpoint").count(), 0);
+ assert.deepEqual(errors, []);
+ await page.context().close();
+ });
+ }
+ });
+}
diff --git a/internal/provider/azure.go b/internal/provider/azure.go
new file mode 100644
index 00000000..3ac1d2c1
--- /dev/null
+++ b/internal/provider/azure.go
@@ -0,0 +1,155 @@
+package provider
+
+import (
+ "context"
+ "net/url"
+ "strings"
+
+ "github.com/yetone/magpie/internal/catalog"
+)
+
+// Azure OpenAI is reached at the user's own resource, never at one host
+// magpie knows: https://.openai.azure.com (or the
+// cognitiveservices.azure.com and services.ai.azure.com hosts newer
+// resources are given). magpie asks it on the v1 API under /openai/v1 —
+// Azure's generally available one since August 2025 — where chat
+// completions, Responses and the model list sit at the paths OpenAI's do,
+// the model in the body is the deployment's name, and no api-version is
+// wanted. The classic /openai/deployments//…?api-version=
+// paths are not used: every endpoint, however it was pasted, is asked at
+// /openai/v1. The key goes in api-key, never as a Bearer: a Bearer on
+// Azure is an Entra ID token.
+
+// AzurePreset is the preset's id.
+const AzurePreset = "azure"
+
+// azureHosts end the hosts an Azure OpenAI resource is served at.
+var azureHosts = []string{".openai.azure.com", ".cognitiveservices.azure.com", ".services.ai.azure.com"}
+
+// AzureHost reports whether host is an Azure OpenAI resource's.
+func AzureHost(host string) bool {
+ host = strings.ToLower(host)
+ if h, _, ok := strings.Cut(host, ":"); ok {
+ host = h
+ }
+ for _, s := range azureHosts {
+ if strings.HasSuffix(host, s) && len(host) > len(s) {
+ return true
+ }
+ }
+ return false
+}
+
+// IsAzure reports whether the provider is Azure OpenAI: made from its
+// preset, or at a resource's host.
+func (p Provider) IsAzure() bool {
+ if p.Preset == AzurePreset {
+ return true
+ }
+ for _, u := range []string{p.Chat, p.Responses} {
+ if u != "" && AzureHost(HostOf(u)) {
+ return true
+ }
+ }
+ return false
+}
+
+// AzureBase is the v1 API base of the resource an endpoint names, as it
+// was pasted from the portal or another app — the bare resource
+// (https://r.openai.azure.com), …/openai, …/openai/v1, a classic
+// deployment's URL with its api-version, or only the resource's name —
+// and ok false when it names no Azure resource.
+func AzureBase(raw string) (string, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", false
+ }
+ if !strings.Contains(raw, "://") {
+ raw = "https://" + raw
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" {
+ return "", false
+ }
+ host := strings.ToLower(u.Host)
+ if !strings.ContainsAny(host, ".:") {
+ // the resource's name alone, as the portal lists it
+ host += ".openai.azure.com"
+ }
+ if !AzureHost(host) {
+ return "", false
+ }
+ return "https://" + host + "/openai/v1", true
+}
+
+// azureEndpoints puts an Azure provider's chat completions and Responses
+// on its resource's v1 API: both are served there, whichever of them the
+// user or the app it came from gave. A provider at a host that is no
+// resource's (an API Management gateway in front of one, or a test's
+// server) keeps its URLs.
+func (p *Provider) azureEndpoints() {
+ given := p.Chat
+ if given == "" {
+ given = p.Responses
+ }
+ if given == "" {
+ return
+ }
+ if h := HostOf(given); strings.HasPrefix(h, "localhost") || p.Preset != AzurePreset && !AzureHost(h) {
+ return
+ }
+ if base, ok := AzureBase(given); ok {
+ p.Chat, p.Responses = base, base
+ }
+}
+
+// asAzure makes a provider brought from another app the Azure preset's,
+// its endpoints on the resource's v1 API.
+func asAzure(p Provider) Provider {
+ pr := Preset(AzurePreset)
+ p.Preset, p.Icon, p.Catalog, p.Website, p.KeysURL = pr.ID, pr.Icon, pr.Catalog, pr.Website, pr.KeysURL
+ if p.ID == "" || p.ID == "default" {
+ p.ID = pr.ID
+ }
+ if n := strings.ToLower(p.Name); n == "" || n == "default" || n == "azure" {
+ p.Name = pr.Name
+ }
+ p.azureEndpoints()
+ return p
+}
+
+// azureDeploymentsVersion is the api-version the data plane's list of
+// deployments is asked with: later versions dropped the list, this one
+// still answers a key.
+const azureDeploymentsVersion = "2022-12-01"
+
+// azureModels lists the resource's deployments — their names are the model
+// ids its v1 API takes — and, where the data plane won't list them, the
+// models the v1 API lists, which are the deployments' ids only when each is
+// named after its model. Asked with the key in api-key alone.
+func (p Provider) azureModels(ctx context.Context) ([]catalog.Model, string, error) {
+ base := strings.TrimRight(p.Chat, "/")
+ if base == "" {
+ base = strings.TrimRight(p.Responses, "/")
+ }
+ root := strings.TrimSuffix(base, "/v1")
+ headers := map[string]string{}
+ for k, v := range AuthHeaders(p, Chat) {
+ headers[k] = v
+ }
+ for k, v := range p.Headers {
+ headers[k] = v
+ }
+ var errs []string
+ for _, u := range []string{root + "/deployments?api-version=" + azureDeploymentsVersion, base + "/models"} {
+ ms, err := catalog.FetchURL(ctx, u, "", false, headers)
+ if err == nil {
+ return ms, base, nil
+ }
+ errs = append(errs, err.Error())
+ if ctx.Err() != nil {
+ break
+ }
+ }
+ return nil, "", errorf("%s — type your deployments' names in as model ids", strings.Join(errs, "; "))
+}
diff --git a/internal/provider/azure_test.go b/internal/provider/azure_test.go
new file mode 100644
index 00000000..806a87ca
--- /dev/null
+++ b/internal/provider/azure_test.go
@@ -0,0 +1,202 @@
+package provider
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func azureHome(t *testing.T) {
+ t.Helper()
+ isolate(t)
+ h := t.TempDir()
+ t.Setenv("HOME", h)
+ t.Setenv("USERPROFILE", h)
+ t.Setenv("APPDATA", filepath.Join(h, "AppData", "Roaming"))
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
+ t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
+ t.Setenv("PATH", h)
+ for _, v := range []string{"CLAUDE_CONFIG_DIR", "CODEX_HOME"} {
+ t.Setenv(v, "")
+ }
+}
+
+// Whatever the portal or another app gave — the resource's endpoint, its
+// /openai, the v1 base, a classic deployment's URL, a newer resource's
+// host, or only its name — magpie asks the resource's v1 API.
+func TestAzureBase(t *testing.T) {
+ for in, want := range map[string]string{
+ "https://contoso.openai.azure.com": "https://contoso.openai.azure.com/openai/v1",
+ "https://contoso.openai.azure.com/": "https://contoso.openai.azure.com/openai/v1",
+ "https://contoso.openai.azure.com/openai": "https://contoso.openai.azure.com/openai/v1",
+ " https://Contoso.openai.azure.com/openai/v1/ ": "https://contoso.openai.azure.com/openai/v1",
+ "https://contoso.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21": "https://contoso.openai.azure.com/openai/v1",
+ "contoso.openai.azure.com": "https://contoso.openai.azure.com/openai/v1",
+ "https://eastus2-x.cognitiveservices.azure.com/": "https://eastus2-x.cognitiveservices.azure.com/openai/v1",
+ "https://proj.services.ai.azure.com/openai/v1": "https://proj.services.ai.azure.com/openai/v1",
+ "contoso": "https://contoso.openai.azure.com/openai/v1",
+ } {
+ if got, ok := AzureBase(in); !ok || got != want {
+ t.Errorf("%q: %q %v, want %q", in, got, ok, want)
+ }
+ }
+ for _, in := range []string{"", "https://api.openai.com/v1", "https://openai.azure.com", "https://apim.example.com/openai"} {
+ if got, ok := AzureBase(in); ok {
+ t.Errorf("%q taken for a resource: %q", in, got)
+ }
+ }
+ if !(Provider{Chat: "https://x.openai.azure.com/openai/v1"}).IsAzure() || (Provider{Chat: "https://api.openai.com/v1"}).IsAzure() {
+ t.Error("IsAzure by host")
+ }
+}
+
+// The preset is filled in with the resource's endpoint alone: saved, both
+// chat completions and Responses are on its v1 API. Without one it isn't
+// saved. The key goes in api-key alone, never as a Bearer.
+func TestAzurePresetSaved(t *testing.T) {
+ azureHome(t)
+ p, err := FromPreset(AzurePreset)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pr := Preset(AzurePreset)
+ if pr.Endpoint == "" || pr.EndpointHint == "" || p.Chat != "" || p.Responses != "" {
+ t.Fatalf("preset: %+v %+v", pr, p)
+ }
+ p.Key = "k"
+ if err := Save(p); err == nil || !strings.Contains(err.Error(), "endpoint") {
+ t.Fatalf("saved without an endpoint: %v", err)
+ }
+ p.Chat, p.Responses = "https://contoso.openai.azure.com/", "https://contoso.openai.azure.com/"
+ if err := Save(p); err != nil {
+ t.Fatal(err)
+ }
+ q, err := Find(AzurePreset)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if q.Chat != "https://contoso.openai.azure.com/openai/v1" || q.Responses != q.Chat || q.Anthropic != "" || !q.IsAzure() {
+ t.Fatalf("saved: %+v", q)
+ }
+ for _, proto := range []Protocol{Chat, Responses} {
+ if h := AuthHeaders(*q, proto); h["api-key"] != "k" || len(h) != 1 {
+ t.Errorf("%s: %v", proto, h)
+ }
+ }
+ // a deployment named for a GPT model on Responses, like OpenAI's own;
+ // its test asks max_completion_tokens
+ if q.Native("gpt-5-codex") != Responses || q.Native("my-deploy") != Chat {
+ t.Errorf("native: %s %s", q.Native("gpt-5-codex"), q.Native("my-deploy"))
+ }
+ url, body := tinyBody(*q, Chat, "my-deploy")
+ if url != "https://contoso.openai.azure.com/openai/v1/chat/completions" || !strings.Contains(body, `"max_completion_tokens":16`) {
+ t.Errorf("test: %s %s", url, body)
+ }
+ if url, _ := tinyBody(*q, Responses, "gpt-5-codex"); url != "https://contoso.openai.azure.com/openai/v1/responses" {
+ t.Errorf("test: %s", url)
+ }
+ // nothing offered from the catalog before the resource's list: a
+ // model not deployed there would be turned away
+ if ms := q.Available(); len(ms) != 0 {
+ t.Errorf("available before a list: %d", len(ms))
+ }
+}
+
+// The resource's deployments are its model ids: asked on the data plane
+// with the key in api-key, no Authorization, and the v1 API's model list
+// where that is refused.
+func TestAzureModels(t *testing.T) {
+ azureHome(t)
+ var mu sync.Mutex
+ var asked []string
+ deployments := true
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ asked = append(asked, r.URL.RequestURI())
+ mu.Unlock()
+ if r.Header.Get("api-key") != "k" || r.Header.Get("Authorization") != "" || r.Header.Get("x-api-key") != "" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ switch {
+ case r.URL.Path == "/openai/deployments" && r.URL.Query().Get("api-version") == "2022-12-01" && deployments:
+ w.Write([]byte(`{"data":[{"id":"team-gpt","model":"gpt-5","object":"deployment","status":"succeeded"},{"id":"gpt-5-codex","model":"gpt-5-codex","object":"deployment","status":"succeeded"}],"object":"list"}`))
+ case r.URL.Path == "/openai/v1/models":
+ w.Write([]byte(`{"object":"list","data":[{"id":"gpt-4.1","object":"model"}]}`))
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte(`{"error":{"code":"404","message":"Resource not found"}}`))
+ }
+ }))
+ defer srv.Close()
+
+ p, _ := FromPreset(AzurePreset)
+ p.Key = "k"
+ p.Chat, p.Responses = srv.URL+"/openai/v1", srv.URL+"/openai/v1"
+ if err := Save(p); err != nil {
+ t.Fatal(err)
+ }
+ q, _ := Find(AzurePreset)
+ ms, err := q.Fetch(context.Background())
+ if err != nil || len(ms) != 2 || ms[0].ID != "team-gpt" || ms[1].ID != "gpt-5-codex" {
+ t.Fatalf("deployments: %v %+v", err, ms)
+ }
+ if asked[0] != "/openai/deployments?api-version=2022-12-01" {
+ t.Errorf("asked %v", asked)
+ }
+ if got := q.Available(); len(got) != 2 {
+ t.Errorf("available %+v", got)
+ }
+
+ deployments = false
+ asked = nil
+ ms, err = q.Fetch(context.Background())
+ if err != nil || len(ms) != 1 || ms[0].ID != "gpt-4.1" {
+ t.Fatalf("v1 models: %v %+v", err, ms)
+ }
+ if len(asked) != 2 || asked[1] != "/openai/v1/models" {
+ t.Errorf("asked %v", asked)
+ }
+
+ q.Key = "wrong"
+ if _, err := q.Fetch(context.Background()); err == nil || !strings.Contains(err.Error(), "deployments' names") {
+ t.Errorf("refused: %v", err)
+ }
+}
+
+// Another app's Azure OpenAI entry is brought in as the preset, at its
+// resource's v1 API, where magpie used to leave it out.
+func TestImportAzure(t *testing.T) {
+ azureHome(t)
+ cfg, _ := os.UserConfigDir()
+ sqliteFixture(t, filepath.Join(cfg, "alma", "chat_threads.db"),
+ `CREATE TABLE providers (id TEXT PRIMARY KEY, name TEXT, type TEXT, api_key TEXT, models TEXT, base_url TEXT, enabled INTEGER, created_at TEXT, api_format TEXT, is_response_api INTEGER, custom_headers TEXT)`,
+ `INSERT INTO providers VALUES ('z1','Azure','azure','az-key','["team-gpt"]','https://contoso.openai.azure.com/openai',1,'1',NULL,0,NULL)`,
+ `INSERT INTO providers VALUES ('z2','Work Azure','azure','az-2','[]','contoso2',1,'2',NULL,0,NULL)`,
+ `INSERT INTO providers VALUES ('z3','Azure','azure','az-3','[]',NULL,1,'3',NULL,0,NULL)`,
+ )
+ items := itemsOf(t, "alma")
+ it := items["z1"]
+ if it.Skip != "" || it.Provider.Preset != AzurePreset || it.Provider.Name != "Azure OpenAI" || it.Provider.Key != "az-key" ||
+ it.Provider.Chat != "https://contoso.openai.azure.com/openai/v1" || it.Provider.Responses != it.Provider.Chat ||
+ len(it.Provider.Models) != 1 || it.Provider.Icon != "azure-color" {
+ t.Fatalf("azure: %+v", it)
+ }
+ if it := items["z2"]; it.Skip != "" || it.Provider.Name != "Work Azure" || it.Provider.Chat != "https://contoso2.openai.azure.com/openai/v1" {
+ t.Fatalf("by name: %+v", it)
+ }
+ if it := items["z3"]; it.Skip == "" {
+ t.Fatalf("no endpoint offered: %+v", it)
+ }
+
+ // an entry at a resource's host from an app that doesn't say Azure
+ im, skip := imported("My Azure", "k", endpoints{chat: "https://contoso.openai.azure.com/openai/deployments/gpt-4o?api-version=2024-10-21"}, nil)
+ if skip != "" || im.Preset != AzurePreset || im.Chat != "https://contoso.openai.azure.com/openai/v1" || im.Responses != im.Chat {
+ t.Fatalf("imported: %q %+v", skip, im)
+ }
+}
diff --git a/internal/provider/importapps.go b/internal/provider/importapps.go
index ce2c16fc..1ee4c68d 100644
--- a/internal/provider/importapps.go
+++ b/internal/provider/importapps.go
@@ -281,7 +281,11 @@ func imported(name, key string, e endpoints, models []string) (Provider, string)
return Provider{}, "it has no base URL"
}
p := Provider{ID: Slug(name), Name: name, Key: key, Chat: e.chat, Responses: e.responses, Anthropic: e.anthropic, Models: models}
- if pr, exact := presetAt(e); pr != nil {
+ if p.IsAzure() {
+ // an Azure OpenAI resource, from any app: the preset's, its
+ // endpoints on the resource's v1 API
+ p = asAzure(p)
+ } else if pr, exact := presetAt(e); pr != nil {
p.ID, p.Icon, p.Catalog = pr.ID, pr.Icon, pr.Catalog
if exact {
p.Preset, p.Website, p.KeysURL = pr.ID, pr.Website, pr.KeysURL
@@ -723,7 +727,15 @@ func readAlma(path string) ([]AppImport, error) {
case almaSignIns[typ]:
skip = "it is a sign-in, not a key; add the subscription in magpie"
case typ == "azure":
- skip = "magpie doesn't support Azure OpenAI yet"
+ // the resource's endpoint (or only its name); one behind a
+ // gateway of the user's is kept as it is, asked as Azure's
+ if b, ok := AzureBase(base); ok {
+ eps.chat = b
+ } else if base != "" {
+ eps.chat = base
+ } else {
+ skip = "it has no Azure OpenAI endpoint"
+ }
case base == "" && Preset(almaPresets[typ]) != nil:
pr := Preset(almaPresets[typ])
eps = endpoints{pr.Chat, pr.Responses, pr.Anthropic}
@@ -738,6 +750,9 @@ func readAlma(path string) ([]AppImport, error) {
}
if skip == "" {
it.Provider, skip = imported(name, key, eps, models)
+ if skip == "" && typ == "azure" {
+ it.Provider = asAzure(it.Provider)
+ }
}
if skip != "" {
it.Provider, it.Skip = Provider{Name: name}, skip
diff --git a/internal/provider/models.go b/internal/provider/models.go
index 28870d8c..577017be 100644
--- a/internal/provider/models.go
+++ b/internal/provider/models.go
@@ -55,6 +55,11 @@ func (p Provider) Available() []catalog.Model {
}
return catalog.Decorate(live, known)
}
+ if p.IsAzure() {
+ // an Azure resource serves its deployments alone, named as the
+ // user likes: the catalog only names the ones its list gave
+ return nil
+ }
if signedIn {
if p.ID == "devin" { // with the variants the user picked
return withDevinContexts(devinCollapse(known, nil, p.Models))
@@ -171,6 +176,10 @@ func (p Provider) fetchOne(ctx context.Context) ([]catalog.Model, string, error)
}
return catalog.WithDrawers(p.planModels(ms), catalog.PublicDrawers(ctx, u)), u, nil
}
+ if p.IsAzure() && (p.Chat != "" || p.Responses != "") {
+ // its deployments, asked with the key in api-key
+ return p.azureModels(ctx)
+ }
var errs []string
for _, proto := range p.Speaks() {
base := p.Base(proto)
diff --git a/internal/provider/presets.go b/internal/provider/presets.go
index f77523fa..d074933c 100644
--- a/internal/provider/presets.go
+++ b/internal/provider/presets.go
@@ -42,6 +42,12 @@ type PresetDef struct {
// NoList: the vendor has no list of models to ask for (Bedrock's
// runtime serves no /models), so Models are its list
NoList bool `json:"noList,omitempty"`
+ // Endpoint, for a vendor reached at the user's own resource (Azure
+ // OpenAI), is an example of its address: the preset gives no URL, and
+ // the editor asks for the one the user's resource is at, with
+ // EndpointHint under it.
+ Endpoint string `json:"endpoint,omitempty"`
+ EndpointHint string `json:"endpointHint,omitempty"`
// Hosts: a vendor serving other makers' models as well as its own
// (Groq, Ollama Cloud), whose list is no maker's word on theirs
Hosts bool `json:"-"`
@@ -251,6 +257,14 @@ var presets = []PresetDef{
"global.openai.gpt-6-astra", "global.openai.gpt-6-sol", "global.openai.gpt-6-luna",
"openai.gpt-oss-120b-1:0", "openai.gpt-oss-20b-1:0", "qwen.qwen3-coder-480b-a35b-v1:0", "deepseek.v3.2",
"moonshotai.kimi-k2.5", "zai.glm-5", "minimax.minimax-m2.5"}},
+ // Azure OpenAI, at the user's own resource (azure.go): its v1 API under
+ // /openai/v1 for chat completions and Responses, the key in api-key,
+ // its deployments' names as the model ids
+ {ID: AzurePreset, Name: "Azure OpenAI", Icon: "azure-color", Kind: KindVendor, Catalog: "azure, openai",
+ Note: "your resource's endpoint and key",
+ Endpoint: "https://.openai.azure.com",
+ EndpointHint: "Your resource's endpoint, from Keys and Endpoint in the Azure portal. magpie asks its v1 API; the model ids are your deployments' names.",
+ Website: "https://ai.azure.com", KeysURL: "https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI"},
// Ollama's own hosted models: the local server's API, at ollama.com with a key
{ID: "ollama-cloud", Name: "Ollama Cloud", Icon: "ollama", Kind: KindVendor, Catalog: "ollama-cloud", Hosts: true,
Chat: "https://ollama.com/v1", Anthropic: "https://ollama.com",
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 57b69d2d..fa989dfb 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -327,6 +327,9 @@ func Save(p Provider) error {
return fmt.Errorf("%q is the id of the %s subscription; pick another name", p.ID, p.ID)
}
if p.Chat == "" && p.Responses == "" && p.Anthropic == "" && p.Decide == "" {
+ if p.Preset == AzurePreset {
+ return errors.New("Azure OpenAI needs your resource's endpoint, e.g. https://.openai.azure.com")
+ }
return errors.New("a provider needs a base URL")
}
if p.Key == "" && !keyOptional(p) {
@@ -555,6 +558,9 @@ func normalize(p Provider) Provider {
if p.Responses == "" && strings.HasSuffix(p.Chat, "/openai/v1") && p.IsBedrock() {
p.Responses = p.Chat
}
+ // Azure OpenAI's resource, however its endpoint was pasted, is asked
+ // on its v1 API, chat completions and Responses both (azure.go)
+ p.azureEndpoints()
// a preset's provider keeps its headers too: the preset gives the
// endpoints and catalog, the headers say which workspace or app it is
p.Headers = cleanHeaders(p.Headers)
@@ -662,7 +668,9 @@ func (p Provider) ResponsesFirst(model string) bool {
if p.Responses != "" && p.IsBedrock() {
return bedrockGPT(model)
}
- if p.Responses == "" || (p.ID != "copilot" && HostOf(p.Responses) != "api.openai.com") {
+ // Azure OpenAI's deployments are named as the user likes; one named
+ // for its model (gpt-5-codex, o4-mini) is taken for it
+ if p.Responses == "" || (p.ID != "copilot" && HostOf(p.Responses) != "api.openai.com" && !p.IsAzure()) {
return false
}
m := strings.ToLower(model[strings.LastIndex(model, "/")+1:])
diff --git a/internal/provider/test.go b/internal/provider/test.go
index a161775c..9fa4a234 100644
--- a/internal/provider/test.go
+++ b/internal/provider/test.go
@@ -64,7 +64,7 @@ func tiny(q Provider, proto Protocol, model string) (url, body string) {
func tinyBody(q Provider, proto Protocol, model string) (url, body string) {
switch proto {
case Chat:
- if q.IsBedrock() {
+ if q.IsBedrock() || q.IsAzure() {
return q.Chat + "/chat/completions", fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_completion_tokens":16}`, model)
}
return q.Chat + "/chat/completions", fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_tokens":16}`, model)
@@ -243,10 +243,18 @@ func isClaude(id string) bool {
// AuthHeaders is how a request to the vendor proves who it is. Anthropic's
// own API wants x-api-key alone, and so does Bedrock's, which turns away a
// request with both (#176); other compatible vendors take either, so both.
+// Azure OpenAI takes a key in api-key alone: a Bearer there is an Entra ID
+// token, and the key sent as one is turned away.
func AuthHeaders(p Provider, proto Protocol) map[string]string {
if p.Key == "" {
return map[string]string{}
}
+ if p.IsAzure() {
+ if proto == Anthropic {
+ return map[string]string{"x-api-key": p.Key}
+ }
+ return map[string]string{"api-key": p.Key}
+ }
if proto == Anthropic {
if strings.HasSuffix(p.Host(), "anthropic.com") || p.IsBedrock() {
return map[string]string{"x-api-key": p.Key}