provider: Azure OpenAI, asked at the user's own resource on its v1 API with the key in api-key, for Codex, Claude Code and chat clients alike, and brought in from other apps instead of left out — Azure OpenAI had no preset, and importing an Azure entry from Alma was skipped with "magpie doesn't support Azure OpenAI yet". A new Azure OpenAI preset (vendor) has no URL of its own: its editor asks for the resource's endpoint in a field above the key, focused first, with https://<resource>.openai.azure.com as the placeholder and a hint on where the portal shows it and that the model ids are the deployments' names (en and zh); adding without one says "Your resource's endpoint is needed" and sends nothing, and Save refuses one without an endpoint too. Whatever is pasted — the bare endpoint, …/openai, …/openai/v1/, a classic deployment URL with its api-version, a *.cognitiveservices.azure.com or *.services.ai.azure.com host, or only the resource's name — is saved as https://<host>/openai/v1 for both chat completions and Responses. magpie uses the v1 GA API rather than the classic /openai/deployments/<name>/…?api-version= paths: the paths match OpenAI's, the body's model is the deployment's name, and no api-version is needed (the reasoning is in internal/provider/azure.go). The key goes in api-key, never as a Bearer, which Azure takes as an Entra ID token; nothing sends Authorization or x-api-key there. Deployments named after GPT/o/codex models go to Responses first, as OpenAI's do, and others to chat completions. Chat bodies ask max_completion_tokens instead of max_tokens and drop thinking/enable_thinking, both in passthrough and in Claude Code's translated requests, and the Test probe asks max_completion_tokens as well. The model list is the resource's deployments (/openai/deployments?api-version=2022-12-01), falling back to /openai/v1/models, with the key in api-key; when neither answers, the error says to type the deployment names in as model ids. Before a list, no catalog models are offered, since a model the resource hasn't deployed would be turned away. An Alma azure entry, and any imported entry at a resource's host, becomes the preset at its v1 base; an Alma azure entry with no endpoint is skipped as "it has no Azure OpenAI endpoint", and one at a non-Azure host (an API Management gateway) keeps its URL and is still signed with api-key. Adds the lobe-icons Azure icon. Tested in Go against fake resources (httptest): TestAzureRoutes checks that Codex /v1/responses, a chat client's /v1/chat/completions and Claude Code's /v1/messages on both a GPT-named and another deployment reach /openai/v1/responses or /openai/v1/chat/completions with api-key set, no Authorization or x-api-key, the deployment name as the model, max_completion_tokens and no thinking; TestAzureBase covers each pasted form and the hosts that aren't resources; TestAzurePresetSaved covers the endpoint required, both URLs on the v1 base, api-key-only headers, Native, the probe's URL and body, and nothing available before a list; TestAzureModels covers deployments first, the v1 fallback and a refused key's message; TestImportAzure covers Alma rows with an endpoint, with a bare resource name and with none, plus a classic URL from another app. azure-endpoint.test.cjs, in Chromium and WebKit, en and zh, checks the field, placeholder, hint and focus, the warning without an endpoint with nothing sent and the page not scrolled, the endpoint sent as chat and responses with the key, and that OpenAI's editor has no such field. add-sheet and old-webkit pass. go build -tags nogui (and GOOS=linux, GOOS=windows), go vet and go test -tags nogui ./... pass (TestClaudeAccountNamedAsItsLogin failed under load and passes alone). Not tried against a real Azure resource: whether a key still lists deployments with api-version 2022-12-01, whether Azure's Responses API accepts every field Codex sends, Claude models on Azure AI Foundry's Anthropic endpoint (not wired), Entra ID tokens, and API Management custom domains beyond keeping their URL. Not tried in the real Wails window.

This commit is contained in:
yetone committed 2026-09-30 02:17:15 +08:00
1 parent 46c56021a4
commit c35471cf5b
13 files changed
+689 -10

No files matched your search

+134
View File
@@ -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)
}
}
})
}
}
+7 -4
View File
@@ -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 {
+14 -1
View File
@@ -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;
}
+5 -1
View File
@@ -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}",
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-0-_R_0_)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-1-_R_0_)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-0-_R_0_" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-1-_R_0_" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-2-_R_0_" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+113
View File
@@ -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://<resource>.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://<resource>.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();
});
}
});
}
+155
View File
@@ -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://<resource>.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/<deployment>/…?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, "; "))
}
+202
View File
@@ -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)
}
}
+17 -2
View File
@@ -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
+9
View File
@@ -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)
+14
View File
@@ -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://<resource>.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",
+9 -1
View File
@@ -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://<resource>.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:])
+9 -1
View File
@@ -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}