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:
1 parent
1caea63804
commit
698da36f31
14 files changed
+499
-11
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user