provider: custom request headers for custom providers

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.
This commit is contained in:
elonnzhang authored and yetone committed 2026-09-24 12:50:51 +08:00
1 parent 2a90ed1030
commit 3ffebeebb6
9 files changed
+202 -23

No files matched your search

+8 -3
View File
@@ -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
+16 -2
View File
@@ -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); }
+102 -3
View File
@@ -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");
+8
View File
@@ -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": "添加",
+14 -12
View File
@@ -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{}
+7
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+29
View File
@@ -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 {
+17 -2
View File
@@ -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 <id> show one provider and its models
magpie provider add <preset> <key> add a preset vendor e.g. magpie provider add deepseek sk-…
magpie provider add <name> k=v… add a custom vendor k: url, anthropic, responses, key, models, catalog
magpie provider add <name> k=v… add a custom vendor k: url, anthropic, responses, key, models, catalog, header.X-Foo
magpie provider key <id> <key> change the API key
magpie provider models <id> [ids…] fetch the vendor's model list, or choose which models to expose
magpie provider test <id> send a tiny request through each endpoint
magpie provider rm <id> 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)
}
}