diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index e8b0fb18..2d44fcc7 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -28,7 +28,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). ## 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`. -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. - A gateway key held to some models (`keyHolds`, #882) gets a 403 for any other model. - Sealed tasks (#619) are checked here. @@ -59,8 +59,8 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). ## Verification ```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/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance' +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|TestSkipsRedaction' ``` The gateway's `TestMain` gives the package a home of its own, so these tests diff --git a/internal/gateway/embeddings.go b/internal/gateway/embeddings.go index 4b751374..423193a5 100644 --- a/internal/gateway/embeddings.go +++ b/internal/gateway/embeddings.go @@ -92,9 +92,14 @@ func (s *Server) retrieve(path, operation string) http.HandlerFunc { // masked once, as the settings say: a document a reranker gives // back has its secrets again all, _ := json.Marshal(req) - w, all, done := redacted(w, all) - defer done() - json.Unmarshal(all, &req) + // unless every try is on this machine or the local network, set + // to go unmasked (provider.SkipsRedaction) + 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 { last := i == len(tries)-1 call.Provider = c.p.ID diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 4d3840d1..54e3ec5b 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -905,6 +905,7 @@ func (s *Server) countTokens(w http.ResponseWriter, r *http.Request) { return } // Count the same masked prompt that generation sends to the vendor. + plain := body w, body, unmask := redacted(w, body) defer unmask() 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) { 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) } @@ -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 r = withWires(r) // 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) defer unmask() // 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)) if askedEffort != "" { body = rewriteModel(body, asked) + plain = rewriteModel(plain, asked) } capture := &captureResponseWriter{ResponseWriter: w} 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 g, ms, isGroup := provider.FindGroup(asked) 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 // group it doesn't name with one it may not use in it keyWho, keyHeld := keyHolds(r) diff --git a/internal/gateway/redact.go b/internal/gateway/redact.go index 5193b66d..e45c54b8 100644 --- a/internal/gateway/redact.go +++ b/internal/gateway/redact.go @@ -3,6 +3,7 @@ package gateway import ( "net/http" + "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/redact" "github.com/yetone/magpie/internal/settings" ) @@ -38,3 +39,32 @@ func redactionOptions() redact.Options { st := settings.Load() 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 +} diff --git a/internal/gateway/unredacted_test.go b/internal/gateway/unredacted_test.go new file mode 100644 index 00000000..4323a386 --- /dev/null +++ b/internal/gateway/unredacted_test.go @@ -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) + } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 43e41357..23d0592f 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -7399,7 +7399,7 @@ function pickedOf(p) { // draftOf is a saved provider as its editor's form holds it. 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, @@ -7540,6 +7540,9 @@ function drawEditor(p, presetID) { // the Web search row, shown while there is an API it can search on const searchable = () => !!((draft.anthropic || "").trim() || (draft.responses || "").trim()); let showSearch = () => {}; + // the Redaction row, shown while every address is on this computer or + // the local network + let showLocal = () => {}; if (custom) { name = input(draft.name, t("e.g. My Relay")); 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"; fillEndpoints(); showSearch(); + showLocal(); }; queueMicrotask(() => slide(seg, "api")); 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"); urlWrap.append(seg, url); // the APIs that answered a detection, taken for the provider: their @@ -7779,6 +7783,21 @@ function drawEditor(p, presetID) { 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 // 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 @@ -7933,7 +7952,7 @@ function drawEditor(p, presetID) { const add = (label, key, ph, hint) => { if (apiField[draft.api] === key) return; 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))); }; 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)); } body.searches = !!draft.searches && searchable(); body.pinUpstream = !!draft.pinUpstream; + body.unredacted = !!draft.unredacted && onLAN(); 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"); 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; } +// 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) { const l = el("label", "tick"); const c = el("input"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 9060b851..878b825c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -3122,6 +3122,9 @@ const I18N = { "Drop the list fetched from the vendor; the models.dev one is used until Refresh": "丢弃从供应商拉取的列表;在重新刷新前使用 models.dev 的列表", "Only through routing groups": "只通过路由分组使用", "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 官方上游", "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 官方接不了时请求会失败,不会换别家", @@ -6671,6 +6674,9 @@ const I18N = { "Drop the list fetched from the vendor; the models.dev one is used until Refresh": "ベンダーから取得したリストを破棄します。更新するまでは models.dev のリストを使います", "Only through routing groups": "ルーティンググループ経由のみ", "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 のみ", "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 が受けられないときは失敗します", @@ -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", "Only through routing groups": "Nur über Weiterleitungsgruppen", "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", "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", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index d57482dd..6aac13a9 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -73,6 +73,9 @@ type providerJSON struct { // API alone (provider.PinUpstream), which only it offers Cline bool `json:"cline,omitempty"` 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" // none, or an address (#237) 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(), 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, - 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{}, 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, @@ -963,6 +966,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) { pr.ZhipuTeam = in.ZhipuTeam pr.Searches = in.Searches pr.PinUpstream = in.PinUpstream + pr.Unredacted = in.Unredacted if in.Name != "" { pr.Name = in.Name } diff --git a/internal/gui/tests/provider-unmasked.test.cjs b/internal/gui/tests/provider-unmasked.test.cjs new file mode 100644 index 00000000..e6270964 --- /dev/null +++ b/internal/gui/tests/provider-unmasked.test.cjs @@ -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, []); + }); + } + } +} diff --git a/internal/gui/unredacted_test.go b/internal/gui/unredacted_test.go new file mode 100644 index 00000000..21bca561 --- /dev/null +++ b/internal/gui/unredacted_test.go @@ -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) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e4bacad7..28a6ebc1 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -164,6 +164,14 @@ type Provider struct { // DeepSeek's own keeps its prompt cache. See ClinePin. 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 // (#237: Codex through one, a vendor at home without): "" follows // 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.Searches = p.Searches || src.Searches + p.Unredacted = p.Unredacted || src.Unredacted if p.Website == "" { p.Website = src.Website } diff --git a/internal/provider/unredacted.go b/internal/provider/unredacted.go new file mode 100644 index 00000000..e5fb9dcf --- /dev/null +++ b/internal/provider/unredacted.go @@ -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 +} diff --git a/internal/provider/unredacted_test.go b/internal/provider/unredacted_test.go new file mode 100644 index 00000000..fbac792c --- /dev/null +++ b/internal/provider/unredacted_test.go @@ -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) + } + } +} diff --git a/providers_cli.go b/providers_cli.go index d100a923..c9e129e2 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -66,6 +66,9 @@ const providerUsage = `usage: magpie provider set my-relay search=yes (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) + 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 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= header.New-Api-User= @@ -613,6 +616,13 @@ func showProvider(p provider.Provider) error { if p.Searches { 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 { case p.Account != nil: 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) } 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": if err := setContext(p, "*", v); err != nil { return err