From c35471cf5bd47bff12669d23ad680abd76072102 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 02:16:23 +0800 Subject: [PATCH] =?UTF-8?q?provider:=20Azure=20OpenAI,=20asked=20at=20the?= =?UTF-8?q?=20user's=20own=20resource=20on=20its=20v1=20API=20with=20the?= =?UTF-8?q?=20key=20in=20api-key,=20for=20Codex,=20Claude=20Code=20and=20c?= =?UTF-8?q?hat=20clients=20alike,=20and=20brought=20in=20from=20other=20ap?= =?UTF-8?q?ps=20instead=20of=20left=20out=20=E2=80=94=20Azure=20OpenAI=20h?= =?UTF-8?q?ad=20no=20preset,=20and=20importing=20an=20Azure=20entry=20from?= =?UTF-8?q?=20Alma=20was=20skipped=20with=20"magpie=20doesn't=20support=20?= =?UTF-8?q?Azure=20OpenAI=20yet".=20A=20new=20Azure=20OpenAI=20preset=20(v?= =?UTF-8?q?endor)=20has=20no=20URL=20of=20its=20own:=20its=20editor=20asks?= =?UTF-8?q?=20for=20the=20resource's=20endpoint=20in=20a=20field=20above?= =?UTF-8?q?=20the=20key,=20focused=20first,=20with=20https://.op?= =?UTF-8?q?enai.azure.com=20as=20the=20placeholder=20and=20a=20hint=20on?= =?UTF-8?q?=20where=20the=20portal=20shows=20it=20and=20that=20the=20model?= =?UTF-8?q?=20ids=20are=20the=20deployments'=20names=20(en=20and=20zh);=20?= =?UTF-8?q?adding=20without=20one=20says=20"Your=20resource's=20endpoint?= =?UTF-8?q?=20is=20needed"=20and=20sends=20nothing,=20and=20Save=20refuses?= =?UTF-8?q?=20one=20without=20an=20endpoint=20too.=20Whatever=20is=20paste?= =?UTF-8?q?d=20=E2=80=94=20the=20bare=20endpoint,=20=E2=80=A6/openai,=20?= =?UTF-8?q?=E2=80=A6/openai/v1/,=20a=20classic=20deployment=20URL=20with?= =?UTF-8?q?=20its=20api-version,=20a=20*.cognitiveservices.azure.com=20or?= =?UTF-8?q?=20*.services.ai.azure.com=20host,=20or=20only=20the=20resource?= =?UTF-8?q?'s=20name=20=E2=80=94=20is=20saved=20as=20https:///openai?= =?UTF-8?q?/v1=20for=20both=20chat=20completions=20and=20Responses.=20magp?= =?UTF-8?q?ie=20uses=20the=20v1=20GA=20API=20rather=20than=20the=20classic?= =?UTF-8?q?=20/openai/deployments//=E2=80=A6=3Fapi-version=3D=20path?= =?UTF-8?q?s:=20the=20paths=20match=20OpenAI's,=20the=20body's=20model=20i?= =?UTF-8?q?s=20the=20deployment's=20name,=20and=20no=20api-version=20is=20?= =?UTF-8?q?needed=20(the=20reasoning=20is=20in=20internal/provider/azure.g?= =?UTF-8?q?o).=20The=20key=20goes=20in=20api-key,=20never=20as=20a=20Beare?= =?UTF-8?q?r,=20which=20Azure=20takes=20as=20an=20Entra=20ID=20token;=20no?= =?UTF-8?q?thing=20sends=20Authorization=20or=20x-api-key=20there.=20Deplo?= =?UTF-8?q?yments=20named=20after=20GPT/o/codex=20models=20go=20to=20Respo?= =?UTF-8?q?nses=20first,=20as=20OpenAI's=20do,=20and=20others=20to=20chat?= =?UTF-8?q?=20completions.=20Chat=20bodies=20ask=20max=5Fcompletion=5Ftoke?= =?UTF-8?q?ns=20instead=20of=20max=5Ftokens=20and=20drop=20thinking/enable?= =?UTF-8?q?=5Fthinking,=20both=20in=20passthrough=20and=20in=20Claude=20Co?= =?UTF-8?q?de's=20translated=20requests,=20and=20the=20Test=20probe=20asks?= =?UTF-8?q?=20max=5Fcompletion=5Ftokens=20as=20well.=20The=20model=20list?= =?UTF-8?q?=20is=20the=20resource's=20deployments=20(/openai/deployments?= =?UTF-8?q?=3Fapi-version=3D2022-12-01),=20falling=20back=20to=20/openai/v?= =?UTF-8?q?1/models,=20with=20the=20key=20in=20api-key;=20when=20neither?= =?UTF-8?q?=20answers,=20the=20error=20says=20to=20type=20the=20deployment?= =?UTF-8?q?=20names=20in=20as=20model=20ids.=20Before=20a=20list,=20no=20c?= =?UTF-8?q?atalog=20models=20are=20offered,=20since=20a=20model=20the=20re?= =?UTF-8?q?source=20hasn't=20deployed=20would=20be=20turned=20away.=20An?= =?UTF-8?q?=20Alma=20azure=20entry,=20and=20any=20imported=20entry=20at=20?= =?UTF-8?q?a=20resource's=20host,=20becomes=20the=20preset=20at=20its=20v1?= =?UTF-8?q?=20base;=20an=20Alma=20azure=20entry=20with=20no=20endpoint=20i?= =?UTF-8?q?s=20skipped=20as=20"it=20has=20no=20Azure=20OpenAI=20endpoint",?= =?UTF-8?q?=20and=20one=20at=20a=20non-Azure=20host=20(an=20API=20Manageme?= =?UTF-8?q?nt=20gateway)=20keeps=20its=20URL=20and=20is=20still=20signed?= =?UTF-8?q?=20with=20api-key.=20Adds=20the=20lobe-icons=20Azure=20icon.=20?= =?UTF-8?q?Tested=20in=20Go=20against=20fake=20resources=20(httptest):=20T?= =?UTF-8?q?estAzureRoutes=20checks=20that=20Codex=20/v1/responses,=20a=20c?= =?UTF-8?q?hat=20client's=20/v1/chat/completions=20and=20Claude=20Code's?= =?UTF-8?q?=20/v1/messages=20on=20both=20a=20GPT-named=20and=20another=20d?= =?UTF-8?q?eployment=20reach=20/openai/v1/responses=20or=20/openai/v1/chat?= =?UTF-8?q?/completions=20with=20api-key=20set,=20no=20Authorization=20or?= =?UTF-8?q?=20x-api-key,=20the=20deployment=20name=20as=20the=20model,=20m?= =?UTF-8?q?ax=5Fcompletion=5Ftokens=20and=20no=20thinking;=20TestAzureBase?= =?UTF-8?q?=20covers=20each=20pasted=20form=20and=20the=20hosts=20that=20a?= =?UTF-8?q?ren't=20resources;=20TestAzurePresetSaved=20covers=20the=20endp?= =?UTF-8?q?oint=20required,=20both=20URLs=20on=20the=20v1=20base,=20api-ke?= =?UTF-8?q?y-only=20headers,=20Native,=20the=20probe's=20URL=20and=20body,?= =?UTF-8?q?=20and=20nothing=20available=20before=20a=20list;=20TestAzureMo?= =?UTF-8?q?dels=20covers=20deployments=20first,=20the=20v1=20fallback=20an?= =?UTF-8?q?d=20a=20refused=20key's=20message;=20TestImportAzure=20covers?= =?UTF-8?q?=20Alma=20rows=20with=20an=20endpoint,=20with=20a=20bare=20reso?= =?UTF-8?q?urce=20name=20and=20with=20none,=20plus=20a=20classic=20URL=20f?= =?UTF-8?q?rom=20another=20app.=20azure-endpoint.test.cjs,=20in=20Chromium?= =?UTF-8?q?=20and=20WebKit,=20en=20and=20zh,=20checks=20the=20field,=20pla?= =?UTF-8?q?ceholder,=20hint=20and=20focus,=20the=20warning=20without=20an?= =?UTF-8?q?=20endpoint=20with=20nothing=20sent=20and=20the=20page=20not=20?= =?UTF-8?q?scrolled,=20the=20endpoint=20sent=20as=20chat=20and=20responses?= =?UTF-8?q?=20with=20the=20key,=20and=20that=20OpenAI's=20editor=20has=20n?= =?UTF-8?q?o=20such=20field.=20add-sheet=20and=20old-webkit=20pass.=20go?= =?UTF-8?q?=20build=20-tags=20nogui=20(and=20GOOS=3Dlinux,=20GOOS=3Dwindow?= =?UTF-8?q?s),=20go=20vet=20and=20go=20test=20-tags=20nogui=20./...=20pass?= =?UTF-8?q?=20(TestClaudeAccountNamedAsItsLogin=20failed=20under=20load=20?= =?UTF-8?q?and=20passes=20alone).=20Not=20tried=20against=20a=20real=20Azu?= =?UTF-8?q?re=20resource:=20whether=20a=20key=20still=20lists=20deployment?= =?UTF-8?q?s=20with=20api-version=202022-12-01,=20whether=20Azure's=20Resp?= =?UTF-8?q?onses=20API=20accepts=20every=20field=20Codex=20sends,=20Claude?= =?UTF-8?q?=20models=20on=20Azure=20AI=20Foundry's=20Anthropic=20endpoint?= =?UTF-8?q?=20(not=20wired),=20Entra=20ID=20tokens,=20and=20API=20Manageme?= =?UTF-8?q?nt=20custom=20domains=20beyond=20keeping=20their=20URL.=20Not?= =?UTF-8?q?=20tried=20in=20the=20real=20Wails=20window.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/azure_test.go | 134 ++++++++++++++ internal/gateway/gateway.go | 11 +- internal/gui/assets/app.js | 15 +- internal/gui/assets/i18n.js | 6 +- internal/gui/assets/icons/azure-color.svg | 1 + internal/gui/tests/azure-endpoint.test.cjs | 113 ++++++++++++ internal/provider/azure.go | 155 ++++++++++++++++ internal/provider/azure_test.go | 202 +++++++++++++++++++++ internal/provider/importapps.go | 19 +- internal/provider/models.go | 9 + internal/provider/presets.go | 14 ++ internal/provider/provider.go | 10 +- internal/provider/test.go | 10 +- 13 files changed, 689 insertions(+), 10 deletions(-) create mode 100644 internal/gateway/azure_test.go create mode 100644 internal/gui/assets/icons/azure-color.svg create mode 100644 internal/gui/tests/azure-endpoint.test.cjs create mode 100644 internal/provider/azure.go create mode 100644 internal/provider/azure_test.go 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 @@ +Azure \ 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}