gateway, providers, gui: a model on this computer or the local network can be set to get requests unmasked by Settings' redaction (lc on Discord: 本地模型可以不脱敏)

The provider editor has a Redaction row, "Send requests unmasked", while
every address of the provider is on this computer or the local network
(localhost, *.local, a loopback or private IP): Ollama, LM Studio, a vLLM
box. Ticked (provider.Unredacted; CLI `magpie provider set <id>
unmasked=yes`), a request whose provider and fallbacks, or whose routing
group's models, all skip redaction (provider.SkipsRedaction) goes to them
as the agent wrote it; anything that may reach another provider stays
masked for all of them. An address edited to a vendor's masks it again; a
signed-in account, a plugin's provider and another magpie never skip. The
log and recent calls keep the masked body. count_tokens and embeddings
follow the same rule.

It is a per-provider choice, not automatic for local addresses, because
a local address isn't a local model: a relay run locally (new-api,
CLIProxyAPI) or Ollama's cloud models pass the request on to a vendor.

Verified: TestLocalUnredactedProviderGetsTheRequestWhole (set, unset,
fallback to a vendor, fallback to another local one, a group of it alone,
a group with a vendor; chat and count_tokens) and TestLocalUnredactedEmbeddings
fail without the gateway change ("sent whole false, want true: … my key is
{{API_KEY_…}}"; count_tokens alone: "counted whole false"); TestSkipsRedaction;
TestProviderSaveKeepsUnredacted fails without the preset save line.
provider-unmasked.test.cjs (Chromium+WebKit, en/zh, 900 and 440 wide) 32/32,
3 of 4 fail on the old app.js; gui-ja, gui-de, i18n-keys, provider-search
and the other provider editor suites pass. go test -race on gateway,
provider, redact, gui; -race -count=10 on the new tests; go vet,
GOOS=linux/windows builds. Real: a sandbox gateway in front of the local
Ollama (llama3 8B) through a logging proxy: unset, Ollama was sent
{{API_KEY_…}}; with unmasked=yes it was sent the key as written, and
answered both times.
This commit is contained in:
yetone committed 2026-10-07 00:33:15 +08:00
1 parent 1caea63804
commit 698da36f31
14 files changed
+499 -11

No files matched your search

+3 -3
View File
@@ -28,7 +28,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
## Runtime path ## Runtime path
1. `handle(proto)` reads the body and the model (`requestBody`, `requestModel`), then calls `serveAgent`. `serveAgent` runs the [middleware](gateway-middleware.md) chain, then `serve`. 1. `handle(proto)` reads the body and the model (`requestBody`, `requestModel`), then calls `serveAgent`. `serveAgent` runs the [middleware](gateway-middleware.md) chain, then `serve`.
2. `serve` redacts secrets in the request (`redacted`), resolves the model to a provider or a group, and refuses what can't be served: 2. `serve` redacts secrets in the request (`redacted`), resolves the model to a provider or a group, and refuses what can't be served. A request whose provider and fallbacks, or whose group's models, all skip redaction (`unredactedRoute`, `provider.SkipsRedaction`: set *Send requests unmasked*, `Unredacted`, with every address on this machine or the local network) goes to them as the agent wrote it; the log and recent calls keep the masked body. count_tokens and embeddings follow the same rule.
- `DecidesModel` gives a 400. - `DecidesModel` gives a 400.
- A gateway key held to some models (`keyHolds`, #882) gets a 403 for any other model. - A gateway key held to some models (`keyHolds`, #882) gets a 403 for any other model.
- Sealed tasks (#619) are checked here. - Sealed tasks (#619) are checked here.
@@ -59,8 +59,8 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
## Verification ## Verification
```sh ```sh
go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain' go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted'
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance' go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestSkipsRedaction'
``` ```
The gateway's `TestMain` gives the package a home of its own, so these tests The gateway's `TestMain` gives the package a home of its own, so these tests
+8 -3
View File
@@ -92,9 +92,14 @@ func (s *Server) retrieve(path, operation string) http.HandlerFunc {
// masked once, as the settings say: a document a reranker gives // masked once, as the settings say: a document a reranker gives
// back has its secrets again // back has its secrets again
all, _ := json.Marshal(req) all, _ := json.Marshal(req)
w, all, done := redacted(w, all) // unless every try is on this machine or the local network, set
defer done() // to go unmasked (provider.SkipsRedaction)
json.Unmarshal(all, &req) if slices.ContainsFunc(tries, func(c candidate) bool { return !c.p.SkipsRedaction() }) {
var done func()
w, all, done = redacted(w, all)
defer done()
json.Unmarshal(all, &req)
}
for i, c := range tries { for i, c := range tries {
last := i == len(tries)-1 last := i == len(tries)-1
call.Provider = c.p.ID call.Provider = c.p.ID
+16 -1
View File
@@ -905,6 +905,7 @@ func (s *Server) countTokens(w http.ResponseWriter, r *http.Request) {
return return
} }
// Count the same masked prompt that generation sends to the vendor. // Count the same masked prompt that generation sends to the vendor.
plain := body
w, body, unmask := redacted(w, body) w, body, unmask := redacted(w, body)
defer unmask() defer unmask()
if m := claudeTierStandIn(agentOf(r), model); m != "" { if m := claudeTierStandIn(agentOf(r), model); m != "" {
@@ -921,6 +922,10 @@ func (s *Server) countTokens(w http.ResponseWriter, r *http.Request) {
if countHeld(w, r, provider.Anthropic, id) { if countHeld(w, r, provider.Anthropic, id) {
return return
} }
// and counts the unmasked one where generation sends that
if _, ms, isGroup := provider.FindGroup(id); ok && unredactedRoute(p, isGroup, ms) {
body = plain
}
s.countOn(w, r, p, model, ok, body) s.countOn(w, r, p, model, ok, body)
} }
@@ -1151,7 +1156,10 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// than once per place a name is looked up below // than once per place a name is looked up below
r = withWires(r) r = withWires(r)
// secrets go as placeholders and come back as they were; the log has // secrets go as placeholders and come back as they were; the log has
// what the vendor saw and said // what the vendor saw and said. plain is the body as the agent wrote
// it, for a request that goes only where the user said it may go
// unmasked (unredactedRoute)
plain := body
w, body, unmask := redacted(w, body) w, body, unmask := redacted(w, body)
defer unmask() defer unmask()
// the reply's model the member that answered, when asked for (#822) // the reply's model the member that answered, when asked for (#822)
@@ -1171,6 +1179,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
asked, askedEffort := askedAt(modelOf(body)) asked, askedEffort := askedAt(modelOf(body))
if askedEffort != "" { if askedEffort != "" {
body = rewriteModel(body, asked) body = rewriteModel(body, asked)
plain = rewriteModel(plain, asked)
} }
capture := &captureResponseWriter{ResponseWriter: w} capture := &captureResponseWriter{ResponseWriter: w}
if wholeBodies { if wholeBodies {
@@ -1298,6 +1307,12 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// before any image is taken out of the request: one may be for images // before any image is taken out of the request: one may be for images
g, ms, isGroup := provider.FindGroup(asked) g, ms, isGroup := provider.FindGroup(asked)
g = g.Live() // a manual group's rules wait g = g.Live() // a manual group's rules wait
// a model on this machine or the local network that the user set to
// go unmasked gets the request as written (lc on Discord); the log
// keeps the masked one
if unredactedRoute(p, isGroup, ms) {
body = plain
}
// a gateway key held to some models (#882) is refused another, or a // a gateway key held to some models (#882) is refused another, or a
// group it doesn't name with one it may not use in it // group it doesn't name with one it may not use in it
keyWho, keyHeld := keyHolds(r) keyWho, keyHeld := keyHolds(r)
+30
View File
@@ -3,6 +3,7 @@ package gateway
import ( import (
"net/http" "net/http"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/redact" "github.com/yetone/magpie/internal/redact"
"github.com/yetone/magpie/internal/settings" "github.com/yetone/magpie/internal/settings"
) )
@@ -38,3 +39,32 @@ func redactionOptions() redact.Options {
st := settings.Load() st := settings.Load()
return redact.Options{Secrets: st.Redact, Personal: st.RedactPersonal, Words: st.RedactWords, Rules: st.RedactRules} return redact.Options{Secrets: st.Redact, Personal: st.RedactPersonal, Words: st.RedactWords, Rules: st.RedactRules}
} }
// unredactedRoute says a request resolved to p, or to the group whose
// models are ms, goes only where the user set requests to go unmasked
// (provider.SkipsRedaction): p and each of its fallbacks, or every model of
// the group (a member's own fallbacks are not the group's, see planGroup).
// One that may go on to anyone else stays masked for all of them.
func unredactedRoute(p provider.Provider, isGroup bool, ms []provider.Member) bool {
if isGroup {
if len(ms) == 0 {
return false
}
for _, m := range ms {
if !m.Provider.SkipsRedaction() {
return false
}
}
return true
}
if !p.SkipsRedaction() {
return false
}
for _, id := range p.Fallback {
// one that resolves to nothing isn't tried (plan)
if fp, _, ok := provider.Resolve(id); ok && !fp.SkipsRedaction() {
return false
}
}
return true
}
+105
View File
@@ -0,0 +1,105 @@
package gateway
import (
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// lc on Discord: a model on this machine or the local network the user set
// to go unmasked (provider.Unredacted) gets the request as the agent wrote
// it, with Settings' redaction on; set so, a chat, a token count and an
// embedding go to it whole. Unset, or able to fall back to a vendor, or in
// a group with one, it stays masked.
func TestLocalUnredactedProviderGetsTheRequestWhole(t *testing.T) {
const key = "sk-proj-abcdEFGH1234ijklMNOP5678qrst"
chat := `{"model":"fake/m1","max_tokens":10,"messages":[{"role":"user","content":"my key is ` + key + `"}]}`
count := `{"model":"fake/m1","messages":[{"role":"user","content":"my key is ` + key + `"}]}`
vendor := provider.Provider{ID: "vendor", Name: "Vendor", Key: "k", Models: []string{"m9"}, Anthropic: "https://api.vendor.example"}
cases := []struct {
name string
local func(*provider.Provider)
group bool
more []provider.Provider
whole bool
}{
{name: "set", local: func(p *provider.Provider) { p.Unredacted = true }, whole: true},
{name: "unset", local: func(p *provider.Provider) {}},
{name: "falls back to a vendor", local: func(p *provider.Provider) { p.Unredacted = true; p.Fallback = []string{"vendor/m9"} }, more: []provider.Provider{vendor}},
{name: "falls back to another set", local: func(p *provider.Provider) { p.Unredacted = true; p.Fallback = []string{"lan/m2"} },
more: []provider.Provider{{ID: "lan", Name: "LAN", Key: "k", Models: []string{"m2"}, Chat: "http://192.168.1.20:8000/v1", Unredacted: true}}, whole: true},
{name: "a group of it alone", local: func(p *provider.Provider) { p.Unredacted = true }, group: true, whole: true},
{name: "a group with a vendor", local: func(p *provider.Provider) { p.Unredacted = true }, group: true, more: []provider.Provider{vendor}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := &fake{ctype: "application/json", reply: `{"id":"msg_1","type":"message","role":"assistant","model":"m1","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":1}}`}
setup(t, provider.Anthropic, f)
p, err := provider.Find("fake")
if err != nil {
t.Fatal(err)
}
tc.local(p)
for _, q := range append([]provider.Provider{*p}, tc.more...) {
if err := provider.Save(q); err != nil {
t.Fatal(err)
}
}
body := chat
if tc.group {
members := []string{"fake/m1"}
if len(tc.more) > 0 {
members = append(members, "vendor/m9")
}
if err := provider.SaveGroup(provider.Group{ID: "mine", Name: "mine", Members: members, Routing: "order"}); err != nil {
t.Fatal(err)
}
body = strings.Replace(chat, "fake/m1", "group/mine", 1)
}
if err := settings.Save(settings.Settings{Redact: true}); err != nil {
t.Fatal(err)
}
if code, reply := post(t, "/v1/messages", body); code != 200 {
t.Fatalf("%d %s", code, reply)
}
if whole := strings.Contains(string(f.got), key); whole != tc.whole {
t.Fatalf("sent whole %v, want %v: %s", whole, tc.whole, f.got)
}
if tc.group {
return
}
f.reply = `{"input_tokens":17}`
if code, reply := post(t, "/v1/messages/count_tokens", count); code != 200 || f.path != "/v1/messages/count_tokens" {
t.Fatalf("count: %d %s to %s", code, reply, f.path)
}
if whole := strings.Contains(string(f.got), key); whole != tc.whole {
t.Fatalf("counted whole %v, want %v: %s", whole, tc.whole, f.got)
}
})
}
}
// Embeddings likewise: to a local model set to go unmasked, whole.
func TestLocalUnredactedEmbeddings(t *testing.T) {
s, l := shelved(t)
p, err := provider.Find("lib")
if err != nil {
t.Fatal(err)
}
p.Unredacted = true
if err := provider.Save(*p); err != nil {
t.Fatal(err)
}
if err := settings.Save(settings.Settings{Redact: true}); err != nil {
t.Fatal(err)
}
secret := "sk-ant-api03-" + strings.Repeat("Ab3x", 20)
if code, raw := postRetrieval(t, s, "/v1/embeddings", `{"model":"lib/embed-1","input":"key `+secret+`"}`); code != 200 {
t.Fatalf("%d %s", code, raw)
}
if sent := l.got("/v1/embeddings"); len(sent) != 1 || !strings.Contains(sent[0], secret) {
t.Fatalf("the local model was sent %v", sent)
}
}
+42 -3
View File
@@ -7399,7 +7399,7 @@ function pickedOf(p) {
// draftOf is a saved provider as its editor's form holds it. // draftOf is a saved provider as its editor's form holds it.
function draftOf(p) { function draftOf(p) {
return { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, decide: p.decide || "", key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : p.decide ? "decide" : "openai", chosen: pickedOf(p), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, searches: !!p.searches, pinUpstream: !!p.pinUpstream, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), outputs: contextsText(p.outputs), compacts: contextsText(p.compacts), keysUrl: p.keysUrl || "", ...proxyDraft(p), ...concurrencyDraft(p) }; return { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, decide: p.decide || "", key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : p.decide ? "decide" : "openai", chosen: pickedOf(p), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, searches: !!p.searches, unredacted: !!p.unredacted, pinUpstream: !!p.pinUpstream, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), outputs: contextsText(p.outputs), compacts: contextsText(p.compacts), keysUrl: p.keysUrl || "", ...proxyDraft(p), ...concurrencyDraft(p) };
} }
// duplicateProvider opens the Add form on a copy of p (#268): its URLs, // duplicateProvider opens the Add form on a copy of p (#268): its URLs,
@@ -7540,6 +7540,9 @@ function drawEditor(p, presetID) {
// the Web search row, shown while there is an API it can search on // the Web search row, shown while there is an API it can search on
const searchable = () => !!((draft.anthropic || "").trim() || (draft.responses || "").trim()); const searchable = () => !!((draft.anthropic || "").trim() || (draft.responses || "").trim());
let showSearch = () => {}; let showSearch = () => {};
// the Redaction row, shown while every address is on this computer or
// the local network
let showLocal = () => {};
if (custom) { if (custom) {
name = input(draft.name, t("e.g. My Relay")); name = input(draft.name, t("e.g. My Relay"));
name.oninput = () => { draft.name = name.value; if (isNew) draft.id = slug(name.value); }; name.oninput = () => { draft.name = name.value; if (isNew) draft.id = slug(name.value); };
@@ -7579,10 +7582,11 @@ function drawEditor(p, presetID) {
url.placeholder = v === "anthropic" ? "https://…" : "https://…/v1"; url.placeholder = v === "anthropic" ? "https://…" : "https://…/v1";
fillEndpoints(); fillEndpoints();
showSearch(); showSearch();
showLocal();
}; };
queueMicrotask(() => slide(seg, "api")); queueMicrotask(() => slide(seg, "api"));
url = input(draft[apiField[draft.api]], draft.api === "anthropic" ? "https://…" : "https://…/v1", "url"); url = input(draft[apiField[draft.api]], draft.api === "anthropic" ? "https://…" : "https://…/v1", "url");
url.oninput = () => { draft[apiField[draft.api]] = url.value; showSearch(); }; url.oninput = () => { draft[apiField[draft.api]] = url.value; showSearch(); showLocal(); };
const urlWrap = el("div", "stack"); const urlWrap = el("div", "stack");
urlWrap.append(seg, url); urlWrap.append(seg, url);
// the APIs that answered a detection, taken for the provider: their // the APIs that answered a detection, taken for the provider: their
@@ -7779,6 +7783,21 @@ function drawEditor(p, presetID) {
ed.append(...row); ed.append(...row);
} }
// a model on this computer or the local network (Ollama, LM Studio, a
// vLLM box) may be sent requests unmasked by Settings' redaction (lc on
// Discord). Only the user can say it is local all the way: a relay run
// locally, or Ollama's cloud models, pass a request on to a vendor
const addressOf = (k) => (draft[k] ?? (isNew && pr ? pr[k] : "")) || "";
const onLAN = () => !p?.account && (p?.preset || pr?.id) !== "remote-magpie" && localAddresses(["chat", "responses", "anthropic", "decide"].map(addressOf));
if (!p?.account) {
const [ltk, lcb] = tick(t("Send requests unmasked"), !!draft.unredacted);
lcb.onchange = () => { draft.unredacted = lcb.checked; };
const row = field(t("Redaction"), ltk, t("For a model running on this computer or your local network: secrets, personal data and your masked words go to it as written. Leave it off for a local relay or proxy that passes requests on to a vendor."));
showLocal = () => { for (const e of row) e.style.display = onLAN() ? "" : "none"; };
showLocal();
ed.append(...row);
}
// a vendor that tells the whole account's balance only to a token of its // a vendor that tells the whole account's balance only to a token of its
// own (AiHubMix's system access token), where a key knows just its own // own (AiHubMix's system access token), where a key knows just its own
// — or a custom provider's, whose Balance URL may not be named yet: a // — or a custom provider's, whose Balance URL may not be named yet: a
@@ -7933,7 +7952,7 @@ function drawEditor(p, presetID) {
const add = (label, key, ph, hint) => { const add = (label, key, ph, hint) => {
if (apiField[draft.api] === key) return; if (apiField[draft.api] === key) return;
const i = input(draft[key], ph, "url"); const i = input(draft[key], ph, "url");
i.oninput = () => { draft[key] = i.value; showSearch(); }; i.oninput = () => { draft[key] = i.value; showSearch(); showLocal(); };
eps.append(...field(t(label), i, t(hint))); eps.append(...field(t(label), i, t(hint)));
}; };
add("OpenAI URL", "chat", "https://…/v1", "if the vendor also serves chat completions"); add("OpenAI URL", "chat", "https://…/v1", "if the vendor also serves chat completions");
@@ -8020,6 +8039,7 @@ function drawEditor(p, presetID) {
if (p) { body.fallback = draft.fallback; body.unlisted = draft.unlisted; body.modelPrefs = modelPrefsOfDraft(); Object.assign(body, routingOfDraft(p)); } if (p) { body.fallback = draft.fallback; body.unlisted = draft.unlisted; body.modelPrefs = modelPrefsOfDraft(); Object.assign(body, routingOfDraft(p)); }
body.searches = !!draft.searches && searchable(); body.searches = !!draft.searches && searchable();
body.pinUpstream = !!draft.pinUpstream; body.pinUpstream = !!draft.pinUpstream;
body.unredacted = !!draft.unredacted && onLAN();
const cx = parseContexts(draft.contexts || ""); const cx = parseContexts(draft.contexts || "");
if (cx.error) return editorError(t("Context window: {v} is not a length like 128k or 1m", { v: cx.error }), "warn"); if (cx.error) return editorError(t("Context window: {v} is not a length like 128k or 1m", { v: cx.error }), "warn");
body.contexts = cx.map; body.contexts = cx.map;
@@ -17880,6 +17900,25 @@ function syncWhen(iso) {
return new Date().toDateString() === d.toDateString() ? t("at {time}", { time }) : d.toLocaleDateString(intlLang()) + " " + time; return new Date().toDateString() === d.toDateString() ? t("at {time}", { time }) : d.toLocaleDateString(intlLang()) + " " + time;
} }
// localAddresses: every address given is on this computer or the local
// network (localhost, *.local, a loopback or private IP), as
// provider.LocalAddresses says, and there is at least one
function localAddresses(urls) {
const given = urls.map((u) => (u || "").trim()).filter(Boolean);
return given.length > 0 && given.every((u) => {
let h;
try { h = new URL(u).hostname.toLowerCase(); } catch { return false; }
if (h.startsWith("[")) h = h.slice(1, -1);
if (h === "localhost" || h.endsWith(".local") || h === "::1") return true;
const v4 = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (v4) {
const a = +v4[1], b = +v4[2];
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
return /^f[cd][0-9a-f]{2}:/.test(h);
});
}
function tick(label, on) { function tick(label, on) {
const l = el("label", "tick"); const l = el("label", "tick");
const c = el("input"); const c = el("input");
+9
View File
@@ -3122,6 +3122,9 @@ const I18N = {
"Drop the list fetched from the vendor; the models.dev one is used until Refresh": "丢弃从供应商拉取的列表;在重新刷新前使用 models.dev 的列表", "Drop the list fetched from the vendor; the models.dev one is used until Refresh": "丢弃从供应商拉取的列表;在重新刷新前使用 models.dev 的列表",
"Only through routing groups": "只通过路由分组使用", "Only through routing groups": "只通过路由分组使用",
"Searches the web by itself": "自己能联网搜索", "Searches the web by itself": "自己能联网搜索",
"Redaction": "脱敏",
"Send requests unmasked": "请求不脱敏",
"For a model running on this computer or your local network: secrets, personal data and your masked words go to it as written. Leave it off for a local relay or proxy that passes requests on to a vendor.": "给运行在这台电脑或局域网里的模型:密钥、个人信息和你设的脱敏词按原样发给它。如果它是本地的中转或代理、会把请求转给供应商,请保持关闭。",
"DeepSeek models only from DeepSeek's own API": "DeepSeek 模型只走 DeepSeek 官方上游", "DeepSeek models only from DeepSeek's own API": "DeepSeek 模型只走 DeepSeek 官方上游",
"Upstream": "上游", "Upstream": "上游",
"Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline 的网关会把一个模型发给它挑的任意上游;钉住后,DeepSeek 模型的请求只发给 DeepSeek 官方,缓存能命中;DeepSeek 官方接不了时请求会失败,不会换别家", "Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline 的网关会把一个模型发给它挑的任意上游;钉住后,DeepSeek 模型的请求只发给 DeepSeek 官方,缓存能命中;DeepSeek 官方接不了时请求会失败,不会换别家",
@@ -6671,6 +6674,9 @@ const I18N = {
"Drop the list fetched from the vendor; the models.dev one is used until Refresh": "ベンダーから取得したリストを破棄します。更新するまでは models.dev のリストを使います", "Drop the list fetched from the vendor; the models.dev one is used until Refresh": "ベンダーから取得したリストを破棄します。更新するまでは models.dev のリストを使います",
"Only through routing groups": "ルーティンググループ経由のみ", "Only through routing groups": "ルーティンググループ経由のみ",
"Searches the web by itself": "自分でウェブ検索する", "Searches the web by itself": "自分でウェブ検索する",
"Redaction": "マスク",
"Send requests unmasked": "リクエストをマスクしない",
"For a model running on this computer or your local network: secrets, personal data and your masked words go to it as written. Leave it off for a local relay or proxy that passes requests on to a vendor.": "このコンピューターかローカルネットワークで動くモデル用:シークレット、個人情報、マスクする語はそのまま送られます。リクエストをベンダーに転送するローカルのリレーやプロキシではオフのままにしてください。",
"DeepSeek models only from DeepSeek's own API": "DeepSeek モデルは DeepSeek 公式 API のみ", "DeepSeek models only from DeepSeek's own API": "DeepSeek モデルは DeepSeek 公式 API のみ",
"Upstream": "上流", "Upstream": "上流",
"Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline のゲートウェイはモデルを任意の上流に送ります。固定すると DeepSeek モデルへのリクエストは DeepSeek 公式だけに送られ、プロンプトキャッシュが効きます。DeepSeek が受けられないときは失敗します", "Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline のゲートウェイはモデルを任意の上流に送ります。固定すると DeepSeek モデルへのリクエストは DeepSeek 公式だけに送られ、プロンプトキャッシュが効きます。DeepSeek が受けられないときは失敗します",
@@ -10235,6 +10241,9 @@ const I18N = {
"Drop the list fetched from the vendor; the models.dev one is used until Refresh": "Die vom Anbieter abgerufene Liste verwerfen; bis zum Aktualisieren gilt die von models.dev", "Drop the list fetched from the vendor; the models.dev one is used until Refresh": "Die vom Anbieter abgerufene Liste verwerfen; bis zum Aktualisieren gilt die von models.dev",
"Only through routing groups": "Nur über Weiterleitungsgruppen", "Only through routing groups": "Nur über Weiterleitungsgruppen",
"Searches the web by itself": "Sucht selbst im Web", "Searches the web by itself": "Sucht selbst im Web",
"Redaction": "Maskierung",
"Send requests unmasked": "Anfragen unmaskiert senden",
"For a model running on this computer or your local network: secrets, personal data and your masked words go to it as written. Leave it off for a local relay or proxy that passes requests on to a vendor.": "Für ein Modell auf diesem Computer oder in Ihrem lokalen Netzwerk: Geheimnisse, persönliche Daten und Ihre maskierten Wörter gehen unverändert an es. Lassen Sie es aus für ein lokales Relay oder einen Proxy, der Anfragen an einen Anbieter weiterreicht.",
"DeepSeek models only from DeepSeek's own API": "DeepSeek-Modelle nur über DeepSeeks eigene API", "DeepSeek models only from DeepSeek's own API": "DeepSeek-Modelle nur über DeepSeeks eigene API",
"Upstream": "Upstream", "Upstream": "Upstream",
"Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline lässt ein Modell von einem beliebigen Anbieter seines Gateways bedienen; angeheftet geht eine Anfrage an ein DeepSeek-Modell nur an DeepSeek, das seinen Prompt-Cache behält, und schlägt fehl, wenn DeepSeek sie nicht annimmt", "Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline lässt ein Modell von einem beliebigen Anbieter seines Gateways bedienen; angeheftet geht eine Anfrage an ein DeepSeek-Modell nur an DeepSeek, das seinen Prompt-Cache behält, und schlägt fehl, wenn DeepSeek sie nicht annimmt",
+5 -1
View File
@@ -73,6 +73,9 @@ type providerJSON struct {
// API alone (provider.PinUpstream), which only it offers // API alone (provider.PinUpstream), which only it offers
Cline bool `json:"cline,omitempty"` Cline bool `json:"cline,omitempty"`
PinUpstream bool `json:"pinUpstream"` PinUpstream bool `json:"pinUpstream"`
// on this machine or the local network, set to go unmasked by
// Settings' redaction (provider.Unredacted)
Unredacted bool `json:"unredacted"`
// the proxy its requests go through: "" the global one, "direct" // the proxy its requests go through: "" the global one, "direct"
// none, or an address (#237) // none, or an address (#237)
Proxy string `json:"proxy"` Proxy string `json:"proxy"`
@@ -402,7 +405,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
ID: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Host: p.Host(), ID: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Host: p.Host(),
Chat: p.Chat, Responses: p.Responses, Anthropic: p.Anthropic, Decide: p.Decide, ModelTest: p.ModelTest(), DecideTest: p.AsksDecideModels(), Chat: p.Chat, Responses: p.Responses, Anthropic: p.Anthropic, Decide: p.Decide, ModelTest: p.ModelTest(), DecideTest: p.AsksDecideModels(),
Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL, Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL,
Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, Headers: p.Headers, Searches: p.Searches, Cline: p.ClinePinnable(), PinUpstream: p.PinUpstream, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, Headers: p.Headers, Searches: p.Searches, Cline: p.ClinePinnable(), PinUpstream: p.PinUpstream, Unredacted: p.Unredacted, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL,
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{}, Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts, Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts,
MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(), PriceRate: p.PriceRate, MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(), PriceRate: p.PriceRate,
@@ -963,6 +966,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
pr.ZhipuTeam = in.ZhipuTeam pr.ZhipuTeam = in.ZhipuTeam
pr.Searches = in.Searches pr.Searches = in.Searches
pr.PinUpstream = in.PinUpstream pr.PinUpstream = in.PinUpstream
pr.Unredacted = in.Unredacted
if in.Name != "" { if in.Name != "" {
pr.Name = in.Name pr.Name = in.Name
} }
@@ -0,0 +1,144 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A model on this computer or the local network (lc on Discord): the
// provider editor's Redaction row, "Send requests unmasked", is there while
// every address of the provider is local (localhost, a private IP), opens as
// it was saved, and Save posts it. A relay at a vendor's address has no such
// row, and one whose address is changed to a vendor's is saved masked. A
// signed-in account has none. In English and Chinese, wide and narrow.
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 base = {
icon: "generic", chat: "", responses: "", anthropic: "", catalog: "",
models: [{ id: "qwen3", name: "Qwen3", on: true }], agents: [], fallback: [], headers: {},
key: { set: false, masked: "" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", searches: false, unredacted: false,
};
const local = { ...base, id: "local", name: "Local Box", chat: "http://localhost:11434/v1" };
const lan = { ...base, id: "lan", name: "LAN Box", chat: "http://192.168.1.20:8000/v1", unredacted: true };
const relay = { ...base, id: "relay", name: "Relay", chat: "https://relay.example.com/v1" };
const codex = {
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "",
models: [{ id: "gpt-6", name: "GPT-6", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
account: { agent: "codex", agentName: "Codex", user: "me@example.com", plan: "PLUS", logins: [{ user: "me@example.com", plan: "PLUS", active: true, on: true }] },
proxy: "",
};
function serve(lang, posts) {
const providers = { providers: [local, lan, relay, codex], presets: [], excluded: [], gateway: { running: true, window: true } };
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
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:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname.startsWith("/api/provider/") && route.request().method() === "POST") {
posts.push({ action: url.pathname.slice("/api/provider/".length), body: route.request().postDataJSON() });
return json(providers);
}
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)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const words = {
en: { tick: "Send requests unmasked", save: "Save" },
zh: { tick: "请求不脱敏", save: "保存" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
for (const width of [900, 440]) {
const w = words[lang];
const open = async (t, name) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${width}-${name}-unmasked.png`), fullPage: true });
}
await browser.close();
});
const page = await (await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const posts = [];
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=providers");
await page.locator(".row.provider", { hasText: name }).click();
await page.locator(".editor .bar").waitFor();
return { page, errors, posts };
};
const row = (page) => page.locator(".editor label.tick", { hasText: w.tick });
const box = (page) => row(page).locator("input");
const save = async (page, posts) => {
const n = posts.length;
await page.locator(".editor .bar").getByRole("button", { name: w.save, exact: true }).click();
for (let i = 0; i < 50 && posts.length === n; i++) await page.waitForTimeout(50);
return posts.at(-1);
};
test(`${engine} ${lang} ${width}: a model on localhost is set to go unmasked, and saved so`, async (t) => {
const { page, errors, posts } = await open(t, "Local Box");
assert.equal(await row(page).isVisible(), true);
assert.equal(await box(page).isChecked(), false, "opens masked");
const r = await row(page).boundingBox();
assert.ok(r.width > 60 && r.x + r.width <= width, `the row fits: ${JSON.stringify(r)}`);
await box(page).scrollIntoViewIfNeeded();
await box(page).check();
const saved = await save(page, posts);
assert.equal(saved.action, "save");
assert.equal(saved.body.id, "local");
assert.equal(saved.body.unredacted, true);
const missing = await page.evaluate(() => [
"Redaction", "Send requests unmasked",
"For a model running on this computer or your local network: secrets, personal data and your masked words go to it as written. Leave it off for a local relay or proxy that passes requests on to a vendor.",
].filter((k) => !I18N.zh[k]));
assert.deepEqual(missing, [], "every string has its Chinese");
assert.deepEqual(errors, []);
});
test(`${engine} ${lang} ${width}: one on the LAN saved unmasked opens ticked, and is saved masked`, async (t) => {
const { page, errors, posts } = await open(t, "LAN Box");
assert.equal(await box(page).isChecked(), true);
await box(page).scrollIntoViewIfNeeded();
await box(page).uncheck();
const saved = await save(page, posts);
assert.equal(saved.body.id, "lan");
assert.equal(saved.body.unredacted, false);
assert.deepEqual(errors, []);
});
test(`${engine} ${lang} ${width}: a relay has no row, and a local one moved to a vendor's address is saved masked`, async (t) => {
{
const { page, errors } = await open(t, "Relay");
assert.equal(await row(page).isVisible(), false);
assert.deepEqual(errors, []);
}
const { page, errors, posts } = await open(t, "Local Box");
await box(page).scrollIntoViewIfNeeded();
await box(page).check();
await page.locator(".editor input[type=url]").first().fill("https://api.vendor.example/v1");
assert.equal(await row(page).isVisible(), false, "the row goes with the local address");
const saved = await save(page, posts);
assert.equal(saved.body.unredacted, false);
assert.deepEqual(errors, []);
});
test(`${engine} ${lang} ${width}: a signed-in account has no such row`, async (t) => {
const { page, errors } = await open(t, "Codex");
assert.equal(await row(page).count(), 0);
assert.deepEqual(errors, []);
});
}
}
}
+44
View File
@@ -0,0 +1,44 @@
package gui
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// lc on Discord: the editor's "Send requests unmasked" is saved on a
// preset added afresh (Ollama) and on one saved again, and /api/providers
// gives it back for the editor to open with.
func TestProviderSaveKeepsUnredacted(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", os.Getenv("HOME"))
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
mux := http.NewServeMux()
providerRoutes(mux, nil)
do := func(method, path, body string) string {
t.Helper()
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(method, path, strings.NewReader(body)))
if w.Code != 200 {
t.Fatalf("%s: %d %s", path, w.Code, w.Body)
}
return w.Body.String()
}
do("POST", "/api/provider/save", `{"id":"ollama","name":"Ollama","preset":"ollama","unredacted":true,"new":true}`)
if p, err := provider.Find("ollama"); err != nil || !p.Unredacted || !p.SkipsRedaction() {
t.Fatalf("a new Ollama: %+v %v", p, err)
}
if !strings.Contains(do("GET", "/api/providers", ""), `"unredacted":true`) {
t.Fatal("the editor isn't told it is unmasked")
}
do("POST", "/api/provider/save", `{"id":"box","name":"Box","chat":"http://192.168.1.20:8000/v1","key":"k","unredacted":true,"new":true}`)
do("POST", "/api/provider/save", `{"id":"box","from":"box","name":"Box","chat":"http://192.168.1.20:8000/v1","unredacted":false}`)
if p, err := provider.Find("box"); err != nil || p.Unredacted {
t.Fatalf("saved masked again: %+v %v", p, err)
}
}
+9
View File
@@ -164,6 +164,14 @@ type Provider struct {
// DeepSeek's own keeps its prompt cache. See ClinePin. // DeepSeek's own keeps its prompt cache. See ClinePin.
PinUpstream bool `json:"pinUpstream,omitempty"` PinUpstream bool `json:"pinUpstream,omitempty"`
// Unredacted has requests to a provider on this machine or the local
// network (Ollama, LM Studio, a vLLM box) go as the agent wrote them,
// unmasked by Settings' redaction, which keeps secrets from vendors
// (lc on Discord). It is the user's word, not the address's: a relay
// run locally, or Ollama's cloud models, pass a request on to a vendor.
// See SkipsRedaction.
Unredacted bool `json:"unredacted,omitempty"`
// Proxy is the proxy magpie's requests to this provider go through // Proxy is the proxy magpie's requests to this provider go through
// (#237: Codex through one, a vendor at home without): "" follows // (#237: Codex through one, a vendor at home without): "" follows
// the global one (Settings' Proxy, the environment's, the system's), // the global one (Settings' Proxy, the environment's, the system's),
@@ -634,6 +642,7 @@ func AddCopy(p Provider, from string) (string, error) {
} }
p.Unlisted = p.Unlisted || src.Unlisted p.Unlisted = p.Unlisted || src.Unlisted
p.Searches = p.Searches || src.Searches p.Searches = p.Searches || src.Searches
p.Unredacted = p.Unredacted || src.Unredacted
if p.Website == "" { if p.Website == "" {
p.Website = src.Website p.Website = src.Website
} }
+33
View File
@@ -0,0 +1,33 @@
package provider
import "net/url"
// SkipsRedaction says requests to p go unmasked: the user set Unredacted,
// and every address p has is on this machine or the local network. One
// edited to a vendor's afterwards is masked again. A signed-in account, a
// plugin's provider and another magpie are always masked: each reaches a
// vendor.
func (p Provider) SkipsRedaction() bool {
if !p.Unredacted || p.Account != nil || p.IsRemoteMagpie() {
return false
}
return LocalAddresses(p.Chat, p.Responses, p.Anthropic, p.Decide)
}
// LocalAddresses says every non-empty base URL among bases is on this
// machine or the local network (localhost, *.local, a loopback or private
// IP), and there is at least one.
func LocalAddresses(bases ...string) bool {
n := 0
for _, b := range bases {
if b == "" {
continue
}
u, err := url.Parse(b)
if err != nil || u.Host == "" || !local(u.Hostname()) {
return false
}
n++
}
return n > 0
}
+32
View File
@@ -0,0 +1,32 @@
package provider
import "testing"
// Only a provider the user set to go unmasked whose every address is on
// this machine or the local network skips redaction; one at a vendor's
// address, a signed-in account or another magpie never does.
func TestSkipsRedaction(t *testing.T) {
cases := []struct {
name string
p Provider
want bool
}{
{"ollama", Provider{Unredacted: true, Chat: "http://localhost:11434/v1", Anthropic: "http://localhost:11434"}, true},
{"loopback ip", Provider{Unredacted: true, Chat: "http://127.0.0.1:1234/v1"}, true},
{"ipv6 loopback", Provider{Unredacted: true, Chat: "http://[::1]:8000/v1"}, true},
{"lan", Provider{Unredacted: true, Chat: "http://192.168.1.20:8000/v1"}, true},
{"mdns", Provider{Unredacted: true, Responses: "http://gpu-box.local:8000/v1"}, true},
{"not set", Provider{Chat: "http://localhost:11434/v1"}, false},
{"a vendor", Provider{Unredacted: true, Chat: "https://api.deepseek.com/v1"}, false},
{"one address a vendor's", Provider{Unredacted: true, Chat: "http://localhost:11434/v1", Anthropic: "https://ollama.com"}, false},
{"a public ip", Provider{Unredacted: true, Chat: "http://8.8.8.8/v1"}, false},
{"no address", Provider{Unredacted: true}, false},
{"an account", Provider{Unredacted: true, Chat: "http://localhost:1/v1", Account: &Account{Agent: "codex"}}, false},
{"another magpie", Provider{Unredacted: true, Preset: RemoteMagpiePreset, Anthropic: "http://192.168.1.5:3425"}, false},
}
for _, tc := range cases {
if got := tc.p.SkipsRedaction(); got != tc.want {
t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
}
}
}
+19
View File
@@ -66,6 +66,9 @@ const providerUsage = `usage:
magpie provider set my-relay search=yes magpie provider set my-relay search=yes
(the relay answers Claude Code's WebSearch and Codex's web_search itself: (the relay answers Claude Code's WebSearch and Codex's web_search itself:
those go to it as sent, not through magpie's own search) those go to it as sent, not through magpie's own search)
magpie provider set ollama unmasked=yes
(a model on this computer or the local network: Settings' redaction leaves its
requests as written; not for a local relay that passes them on to a vendor)
magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… balance=https://relay.example.com/api/usage/token balance.path='$data.total_available / 500000' magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… balance=https://relay.example.com/api/usage/token balance.path='$data.total_available / 500000'
magpie provider set my-relay balance.path='(1 - credits.monthlyCredits / 70) %' magpie provider set my-relay balance.path='(1 - credits.monthlyCredits / 70) %'
magpie provider set my-relay balance=https://relay.example.com/api/user/self balance.path='$data.quota / 500000' balance.token=<access token> header.New-Api-User=<user id> magpie provider set my-relay balance=https://relay.example.com/api/user/self balance.path='$data.quota / 500000' balance.token=<access token> header.New-Api-User=<user id>
@@ -613,6 +616,13 @@ func showProvider(p provider.Provider) error {
if p.Searches { if p.Searches {
kv("search", "by itself"+muted.Render(" a client's web search goes to it as sent")) kv("search", "by itself"+muted.Render(" a client's web search goes to it as sent"))
} }
if p.Unredacted {
why := " local: Settings' redaction leaves its requests as written"
if !p.SkipsRedaction() {
why = " but masked: not every address of it is on this computer or the local network"
}
kv("unmasked", "yes"+muted.Render(why))
}
switch { switch {
case p.Account != nil: case p.Account != nil:
who := p.Account.User who := p.Account.User
@@ -748,6 +758,15 @@ func applyPairs(p *provider.Provider, pairs []string) error {
return fmt.Errorf("search=yes|no, not %q", v) return fmt.Errorf("search=yes|no, not %q", v)
} }
p.Searches = v == "yes" p.Searches = v == "yes"
case "unmasked":
// yes: a model on this computer or the local network gets the
// requests as written, unmasked by Settings' redaction (lc on
// Discord); only while every address of it is local
// (provider.SkipsRedaction)
if v != "yes" && v != "no" {
return fmt.Errorf("unmasked=yes|no, not %q", v)
}
p.Unredacted = v == "yes"
case "context": case "context":
if err := setContext(p, "*", v); err != nil { if err := setContext(p, "*", v); err != nil {
return err return err