From 3ffebeebb613f0bd8dd402f2bd7510c44dce9ef2 Mon Sep 17 00:00:00 2001 From: elonnzhang Date: Thu, 24 Sep 2026 12:16:51 +0800 Subject: [PATCH] provider: custom request headers for custom providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extra HTTP headers, typed by the user, ride on every request magpie makes to a plain key+URL provider — forwarded calls, connectivity tests, and model-list fetches — applied after the auth headers. Presets and signed-in agent accounts ignore them. Editable in the GUI (with a JSON value mode) and via the CLI as header.X-Foo=bar. --- internal/catalog/live.go | 11 +++- internal/gui/assets/app.css | 18 +++++- internal/gui/assets/app.js | 105 +++++++++++++++++++++++++++++++++- internal/gui/assets/i18n.js | 8 +++ internal/gui/providers.go | 26 +++++---- internal/provider/account.go | 7 +++ internal/provider/models.go | 2 +- internal/provider/provider.go | 29 ++++++++++ providers_cli.go | 19 +++++- 9 files changed, 202 insertions(+), 23 deletions(-) diff --git a/internal/catalog/live.go b/internal/catalog/live.go index 175e8f45..0fde19d7 100644 --- a/internal/catalog/live.go +++ b/internal/catalog/live.go @@ -65,7 +65,7 @@ func SaveLive(provider, base string, models []Model) error { // Anthropic version header, which the official API requires but which a // dual-protocol relay like OpenRouter reads as a request for its // Anthropic-flavoured catalog — namespaced, differently named ids. -func Fetch(ctx context.Context, base, key string, anthropic bool) ([]Model, error) { +func Fetch(ctx context.Context, base, key string, anthropic bool, headers map[string]string) ([]Model, error) { base = strings.TrimRight(strings.TrimSpace(base), "/") if base == "" { return nil, errors.New("no base URL") @@ -92,7 +92,7 @@ func Fetch(ctx context.Context, base, key string, anthropic bool) ([]Model, erro var lastErr error for _, u := range urls { - ms, err := fetchOne(ctx, u, key, anthropic) + ms, err := fetchOne(ctx, u, key, anthropic, headers) if err == nil && len(ms) > 0 { return ms, nil } @@ -109,7 +109,7 @@ func Fetch(ctx context.Context, base, key string, anthropic bool) ([]Model, erro return nil, lastErr } -func fetchOne(ctx context.Context, url, key string, anthropic bool) ([]Model, error) { +func fetchOne(ctx context.Context, url, key string, anthropic bool, headers map[string]string) ([]Model, error) { ctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -125,6 +125,11 @@ func fetchOne(ctx context.Context, url, key string, anthropic bool) ([]Model, er if anthropic { req.Header.Set("anthropic-version", "2023-06-01") } + // The user's own headers, after the defaults so a private auth scheme + // wins. Written directly so the name keeps the exact case the user typed. + for k, v := range headers { + req.Header[k] = []string{v} + } res, err := http.DefaultClient.Do(req) if err != nil { return nil, err diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 3328e655..fe1bd55c 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -554,13 +554,27 @@ body.window .foot { padding-left: 18px; } font-size: 12.5px; outline: none; width: 100%; min-width: 0; transition: border-color .12s, box-shadow .12s; } -.editor input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } +.editor input:focus, .editor input:focus-visible, .editor .headers textarea.json:focus, .editor .headers textarea.json:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; } .editor input::placeholder { color: var(--faint); } .editor .pair { display: flex; gap: 8px; align-items: center; } .editor .pair input:first-child { flex: 1; } -.editor .side { display: flex; gap: 4px; align-items: center; flex: none; } +.editor .side { display: flex; gap: 4px; align-items: center; flex: none; min-width: 52px; justify-content: flex-end; } .editor .side .text { padding: 3px 6px; } +.editor .headers .pair .side .text { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border-radius: 6px; } .editor .stack { display: flex; flex-direction: column; gap: 6px; } +.editor .headers { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; } +.editor .headers .pair { width: 100%; } +.editor .headers .pair input { flex: 1; min-width: 0; } +.editor .headers .pair input:first-child { flex: 4; } +.editor .headers .pair input:nth-child(2) { flex: 6; } +.editor .headers .pair .side { flex: none; } +.editor .headers .pair .side .text.on { color: var(--accent); } +.editor .headers .pair.col { flex-direction: column; align-items: stretch; } +.editor .headers .pair.col .hhead { display: flex; gap: 8px; align-items: center; width: 100%; } +.editor .headers .pair.col .hhead input { flex: 1; min-width: 0; } +.editor .headers .pair.col .side { flex: none; } +.editor .headers textarea.json { width: 100%; box-sizing: border-box; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); outline: none; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; resize: vertical; tab-size: 2; white-space: pre; overflow: auto; transition: border-color .12s, box-shadow .12s; } +.editor .headers textarea.json.bad { border-color: var(--danger, #d66); } .segs { position: relative; display: inline-flex; padding: 2px; border-radius: 7px; background: var(--pill); align-self: flex-start; } .opt { position: relative; z-index: 1; height: 22px; padding: 0 10px; border-radius: 5px; font-size: 12px; color: var(--muted); transition: color .18s var(--ease), transform .14s var(--ease); } .opt.on { color: var(--fg); } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index d19723ce..8536855f 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1136,6 +1136,96 @@ function input(value, placeholder, type = "text") { } function cancelEdit() { editing = null; draft = null; importing = null; renderProviders(); } +// Custom request headers: the draft keeps them as an ordered [name, value, +// json?] list so a half-typed row (and its open JSON editor) survives a +// re-render; headersOf folds that back into the object the backend stores, +// dropping rows with an empty name and minifying any value that parses as +// JSON — a header value is one line, so pretty-printing is display-only. +function headerRows(obj) { + return Object.entries(obj || {}).map(([k, v]) => [k, v, false]); +} +function headersOf(rows) { + const out = {}; + for (const [k, v] of rows || []) { + const name = (k || "").trim(); + if (!name) continue; + const val = (v || "").trim(); + // minify only a JSON blob; a plain value like "2.0" must stay verbatim + out[name] = looksJSON(val) ? minifyJSON(val) : val; + } + return out; +} +// minifyJSON collapses a value to one line when it is valid JSON, so a +// pretty-printed blob in the editor never reaches the wire with newlines. +function minifyJSON(s) { + try { return JSON.stringify(JSON.parse(s)); } catch { return s; } +} +function prettyJSON(s) { + try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return s; } +} +function looksJSON(s) { + s = (s || "").trim(); + return s.startsWith("{") || s.startsWith("["); +} +function headerEditor() { + const box = el("div", "headers"); + const render = () => { + box.replaceChildren(); + draft.headers.forEach((row, i) => { + const line = el("div", "pair"); + const name = input(row[0], t("Header-Name")); + name.oninput = () => { row[0] = name.value; }; + + // the value: a one-line box, or (when the row is expanded) a full-width + // textarea that pretty-prints JSON for editing. The toggle is always + // offered — a value only becomes JSON after you paste it in. + let valCtl; + if (row[2]) { + valCtl = el("textarea", "json"); + valCtl.value = prettyJSON(row[1]); + valCtl.spellcheck = false; + valCtl.wrap = "off"; // each JSON line stays on one line; scroll instead + valCtl.rows = Math.min(16, Math.max(4, valCtl.value.split("\n").length)); + valCtl.placeholder = t("value"); + valCtl.oninput = () => { + row[1] = valCtl.value; + valCtl.classList.toggle("bad", looksJSON(valCtl.value) && !parses(valCtl.value)); + }; + valCtl.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") cancelEdit(); }; + } else { + valCtl = input(row[1], t("value")); + valCtl.oninput = () => { row[1] = valCtl.value; }; + } + + const side = el("div", "side"); + const j = el("button", "text" + (row[2] ? " on" : ""), "{ }"); + j.title = row[2] ? t("Collapse to one line") : t("Edit as JSON"); + j.onclick = () => { row[2] = !row[2]; if (!row[2]) row[1] = minifyJSON(row[1]); render(); }; + side.append(j); + const del = el("button", "text danger", "×"); + del.title = t("Remove header"); + del.onclick = () => { draft.headers.splice(i, 1); render(); }; + side.append(del); + + if (row[2]) { + line.classList.add("col"); + const top = el("div", "hhead"); + top.append(name, side); + line.append(top, valCtl); + } else { + line.append(name, valCtl, side); + } + box.append(line); + }); + const add = el("button", "text", t("+ Add header")); + add.onclick = () => { draft.headers.push(["", "", false]); render(); }; + box.append(add); + }; + render(); + return box; +} +function parses(s) { try { JSON.parse(s); return true; } catch { return false; } } + // ---------- modal ---------- // The provider editor opens as a dialog over the page; Escape, the backdrop // or Cancel close it. @@ -1186,10 +1276,10 @@ function renderEditor(p, presetID) { const pr = presetID ? providers.presets.find((x) => x.id === presetID) : p?.preset ? providers.presets.find((x) => x.id === p.preset) : null; const isNew = !p, custom = !pr; draft = draft || (p - ? { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, key: "", api: p.anthropic && !p.chat ? "anthropic" : "openai", chosen: p.models.filter((m) => m.on).map((m) => m.id), extra: [] } + ? { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, key: "", api: p.anthropic && !p.chat ? "anthropic" : "openai", chosen: p.models.filter((m) => m.on).map((m) => m.id), extra: [], headers: headerRows(p.headers) } : pr - ? { id: pr.id, name: pr.name, preset: pr.id, key: "", chosen: [], extra: [] } - : { id: "", name: "", preset: "", chat: "", responses: "", anthropic: "", catalog: "", key: "", api: "openai", chosen: [], extra: [] }); + ? { id: pr.id, name: pr.name, preset: pr.id, key: "", chosen: [], extra: [], headers: [] } + : { id: "", name: "", preset: "", chat: "", responses: "", anthropic: "", catalog: "", key: "", api: "openai", chosen: [], extra: [], headers: [] }); const ed = el("div", "editor" + (isNew ? " new" : "")); ed.onclick = (e) => e.stopPropagation(); @@ -1290,6 +1380,14 @@ function renderEditor(p, presetID) { keyWrap.append(key, side); ed.append(...field(t("API key"), keyWrap, isNew ? t("Kept in ~/.config/magpie/providers.json, readable by you alone. Nothing is read from your shell.") : "")); + // Custom request headers are only supported for a user-defined provider. + // Presets own their request shape, and signed-in accounts returned above + // use the agent's own authentication headers. + if (custom) { + if (!draft.headers.length) draft.headers.push(["", ""]); + ed.append(...field(t("Headers"), headerEditor(), t("Extra HTTP headers sent to the vendor, applied after auth. For gateways that need a private scheme."))); + } + // a relay that offers several regional endpoints: one selector, and the // provider's base URLs follow it let refreshEndpoints = () => {}; @@ -1359,6 +1457,7 @@ function renderEditor(p, presetID) { const saveBtn = el("button", "text primary", t(isNew ? "Add" : "Save")); const save = () => { const body = { id: draft.id, name: draft.name, preset: draft.preset, key: draft.key || "", chat: draft.chat, responses: draft.responses, anthropic: draft.anthropic, catalog: draft.catalog, models: p ? draft.chosen : draft.extra }; + if (custom) body.headers = headersOf(draft.headers); if (isNew && custom && !body.name) { name.focus(); return status(t("Give it a name"), "warn"); } if (isNew && custom && !body.chat && !body.anthropic) { url.focus(); return status(t("A base URL is needed"), "warn"); } saveBtn.classList.add("busy"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 8cd82696..fc11ce7c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -151,6 +151,14 @@ const I18N = { "models.dev id, e.g. openai": "models.dev 上的 ID,例如 openai", "Catalog": "目录", "Display names and reasoning levels for the models": "模型的显示名称与推理等级", + "Headers": "请求头", + "Extra HTTP headers sent to the vendor, applied after auth. For gateways that need a private scheme.": "发送给供应商的额外 HTTP 请求头,在鉴权之后应用。适用于需要私有鉴权方案的网关。", + "Header-Name": "请求头名称", + "value": "值", + "Remove header": "删除请求头", + "+ Add header": "+ 添加请求头", + "Edit as JSON": "以 JSON 编辑", + "Collapse to one line": "折叠为单行", "Remove": "移除", "{name} removed": "已移除 {name}", "Add": "添加", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 7b740522..132865da 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -25,17 +25,18 @@ type modelJSON struct { } type providerJSON struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Preset string `json:"preset"` - Host string `json:"host"` - Chat string `json:"chat"` - Responses string `json:"responses"` - Anthropic string `json:"anthropic"` - Catalog string `json:"catalog"` - Website string `json:"website"` - KeysURL string `json:"keysUrl"` + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Preset string `json:"preset"` + Host string `json:"host"` + Chat string `json:"chat"` + Responses string `json:"responses"` + Anthropic string `json:"anthropic"` + Catalog string `json:"catalog"` + Website string `json:"website"` + KeysURL string `json:"keysUrl"` + Headers map[string]string `json:"headers,omitempty"` Key struct { Set bool `json:"set"` Masked string `json:"masked"` @@ -111,7 +112,8 @@ func providerInfo(p provider.Provider, agents []*agent.Agent) providerJSON { ID: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Host: p.Host(), Chat: p.Chat, Responses: p.Responses, Anthropic: p.Anthropic, Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL, - Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{}, + Headers: p.Headers, + Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{}, } if out.Chosen == nil { out.Chosen = []string{} diff --git a/internal/provider/account.go b/internal/provider/account.go index a08037af..bd4fab3c 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -56,6 +56,13 @@ func (p Provider) Sign(ctx context.Context, req *http.Request, proto Protocol, b for k, v := range AuthHeaders(p, proto) { req.Header.Set(k, v) } + // The user's own headers ride on plain key+URL providers, after auth so + // they can override a default when a gateway insists on a private scheme. + // Written to the map directly, not via Set, so the name keeps the exact + // case the user typed — some gateways match header names case-sensitively. + for k, v := range p.Headers { + req.Header[k] = []string{v} + } return nil } diff --git a/internal/provider/models.go b/internal/provider/models.go index 7e5fae5f..dec0dcda 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -54,7 +54,7 @@ func (p Provider) Fetch(ctx context.Context) ([]catalog.Model, error) { } var lastErr error for _, proto := range p.Speaks() { - ms, err := catalog.Fetch(ctx, p.Base(proto), p.Key, proto == Anthropic) + ms, err := catalog.Fetch(ctx, p.Base(proto), p.Key, proto == Anthropic, p.Headers) if err == nil { return ms, catalog.SaveLive(p.ID, p.Base(proto), ms) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 52388ddd..d0162a74 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -44,6 +44,14 @@ type Provider struct { Responses string `json:"responses,omitempty"` Anthropic string `json:"anthropic,omitempty"` + // Headers are extra HTTP request headers sent to the vendor, exactly as + // the user typed them. They ride on every request magpie makes to a plain + // key+URL provider — forwarded calls, connectivity tests, and model-list + // fetches — applied after the auth headers, so the user can override those + // when a gateway insists on a private scheme. Signed-in agent accounts + // ignore them: their auth is the agent's own. + Headers map[string]string `json:"headers,omitempty"` + // Models the user chose to expose. Empty means "the preset's picks, or // everything the vendor lists when that list is short". Models []string `json:"models,omitempty"` @@ -251,6 +259,12 @@ func normalize(p Provider) Provider { } } p.Models = cleanList(p.Models) + p.Headers = cleanHeaders(p.Headers) + if p.Preset != "" { + // Presets own their request shape; custom headers are supported only + // for user-defined providers. + p.Headers = nil + } if pr := Preset(p.Preset); pr != nil { if p.Icon == "" { p.Icon = pr.Icon @@ -278,6 +292,21 @@ func cleanList(xs []string) []string { return out } +// cleanHeaders trims header names and values and drops entries with an empty +// name, returning nil when nothing is left so the field stays out of the JSON. +func cleanHeaders(h map[string]string) map[string]string { + out := make(map[string]string, len(h)) + for k, v := range h { + if k = strings.TrimSpace(k); k != "" { + out[k] = strings.TrimSpace(v) + } + } + if len(out) == 0 { + return nil + } + return out +} + func contains(xs []string, x string) bool { for _, v := range xs { if v == x { diff --git a/providers_cli.go b/providers_cli.go index b6758eb2..15d559eb 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "maps" "os" + "slices" "strings" "time" @@ -22,14 +24,15 @@ const providerUsage = `usage: magpie presets list the vendors magpie knows out of the box magpie provider show one provider and its models magpie provider add add a preset vendor e.g. magpie provider add deepseek sk-… - magpie provider add k=v… add a custom vendor k: url, anthropic, responses, key, models, catalog + magpie provider add k=v… add a custom vendor k: url, anthropic, responses, key, models, catalog, header.X-Foo magpie provider key change the API key magpie provider models [ids…] fetch the vendor's model list, or choose which models to expose magpie provider test send a tiny request through each endpoint magpie provider rm remove a provider e.g. magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… - magpie provider add "Own Claude" anthropic=https://gw.example.com key=sk-… catalog=anthropic` + magpie provider add "Own Claude" anthropic=https://gw.example.com key=sk-… catalog=anthropic + magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… header.X-Org-Id=acme` // providers: `magpie providers` func providers() error { @@ -344,6 +347,9 @@ func showProvider(p provider.Provider) error { kv("catalog", p.Catalog) kv("website", p.Website) kv("keys", p.KeysURL) + for _, k := range slices.Sorted(maps.Keys(p.Headers)) { + kv("header", k+": "+muted.Render(p.Headers[k])) + } ms := p.Exposed() src := "models.dev" if t, ok := p.Fetched(); ok { @@ -397,6 +403,15 @@ func applyPairs(p *provider.Provider, pairs []string) error { case "icon": p.Icon = v default: + // header.X-Foo=bar sets a custom request header (name kept as typed) + if len(k) > len("header.") && strings.EqualFold(k[:len("header.")], "header.") { + name := k[len("header."):] + if p.Headers == nil { + p.Headers = map[string]string{} + } + p.Headers[name] = v + continue + } return fmt.Errorf("unknown field %q\n\n%s", k, providerUsage) } }