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:
1 parent
2a90ed1030
commit
3ffebeebb6
9 files changed
+202
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user