diff --git a/internal/gateway/claude_subscription.go b/internal/gateway/claude_subscription.go index 4d284ef7..6bf19e56 100644 --- a/internal/gateway/claude_subscription.go +++ b/internal/gateway/claude_subscription.go @@ -253,7 +253,7 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, oau args := claudeCLIArgs(model, string(mcpConfig), req.Effort, req.WebSearch) cmd := proc.CommandContext(context.Background(), binary, args...) cmd.Dir = tmp - cmd.Env = netproxy.Env(cleanClaudeEnv(os.Environ())) + cmd.Env = netproxy.EnvWith(provider.ProxyOf("claude"), cleanClaudeEnv(os.Environ())) if oauth != "" { // a saved account in use beside the one Claude Code is signed in to cmd.Env = append(cmd.Env, "CLAUDE_CODE_OAUTH_TOKEN="+oauth) diff --git a/internal/gateway/claude_warmup.go b/internal/gateway/claude_warmup.go index 1647d31b..482056b7 100644 --- a/internal/gateway/claude_warmup.go +++ b/internal/gateway/claude_warmup.go @@ -10,6 +10,7 @@ import ( "github.com/yetone/magpie/internal/netproxy" "github.com/yetone/magpie/internal/proc" + "github.com/yetone/magpie/internal/provider" ) // warmClaude sends a Claude account one "hi" through Claude Code, as the @@ -30,7 +31,7 @@ func warmClaude(ctx context.Context, oauth string) error { cmd := proc.CommandContext(ctx, binary, claudeWarmArgs()...) cmd.Dir = tmp cmd.Stdin = strings.NewReader("hi") - cmd.Env = netproxy.Env(cleanClaudeEnv(os.Environ())) + cmd.Env = netproxy.EnvWith(provider.ProxyOf("claude"), cleanClaudeEnv(os.Environ())) if oauth != "" { cmd.Env = append(cmd.Env, "CLAUDE_CODE_OAUTH_TOKEN="+oauth) } diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index c0606355..3b9231fc 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -242,7 +242,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri } var res *http.Response for tries := 0; ; tries++ { - req, err := http.NewRequestWithContext(r.Context(), r.Method, u, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(provider.Via(r.Context(), "codex"), r.Method, u, bytes.NewReader(body)) if err != nil { writeError(w, provider.Responses, 502, err.Error()) end(502, err.Error(), 0, 0) @@ -482,7 +482,7 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) { if r.URL.RawQuery != "" { u += "?" + r.URL.RawQuery } - if req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u, nil); err == nil { + if req, err := http.NewRequestWithContext(provider.Via(r.Context(), "codex"), http.MethodGet, u, nil); err == nil { copyHeaders(req.Header, r.Header) req.Header.Del("Accept-Encoding") if res, err := s.client.Do(req); err == nil { diff --git a/internal/gateway/decide.go b/internal/gateway/decide.go index 23ea26df..89fec4d2 100644 --- a/internal/gateway/decide.go +++ b/internal/gateway/decide.go @@ -348,6 +348,7 @@ func (s *Server) systemOne(ctx context.Context, p provider.Provider, model strin // the provider answers in another one. DecideURL and Sign errors are as // they are; a transport error is named with p. func (s *Server) postDecide(ctx context.Context, p provider.Provider, model string, body []byte) (int, []byte, string, error) { + ctx = p.Via(ctx) u, err := p.DecideURL(ctx) if err != nil { return 0, nil, "", err diff --git a/internal/gateway/draw.go b/internal/gateway/draw.go index 6b5dc881..0512ed72 100644 --- a/internal/gateway/draw.go +++ b/internal/gateway/draw.go @@ -482,6 +482,7 @@ func (s *Server) drawOn(ctx context.Context, p provider.Provider, model string, // send posts to the vendor and reads its answer; a failure's code is the // vendor's, with its own message. func (s *Server) send(ctx context.Context, p provider.Provider, url, contentType string, body []byte, sign bool) ([]byte, int, error) { + ctx = p.Via(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, 500, err diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 7cdf8351..650574dc 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -205,13 +205,15 @@ type Server struct { func New() *Server { redact.SetKeyPath(filepath.Join(settings.Dir(), "redact.key")) return &Server{ - client: &http.Client{Transport: &http.Transport{ + // a provider with a proxy of its own is sent through a transport + // kept for that proxy (#237) + client: &http.Client{Transport: netproxy.Dispatch(&http.Transport{ Proxy: netproxy.Func, ResponseHeaderTimeout: 10 * time.Minute, MaxIdleConnsPerHost: 8, IdleConnTimeout: 90 * time.Second, ForceAttemptHTTP2: true, - }}, + })}, unfit: make(map[string]bool), subscription: newSubscriptionBridge(), debug: os.Getenv("MAGPIE_DEBUG") != "", @@ -1077,6 +1079,9 @@ func sinceStart(before, d time.Duration) int64 { // attempt sends a request to one provider. call.To stays empty when the // provider has no endpoint to send it to. func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, model string, body []byte, call *Call) (int, string) { + // every request to the provider goes through its own proxy, if it has + // one (#237) + r = r.WithContext(p.Via(r.Context())) // A Claude Code subscription must run through the genuine binary. Direct // OAuth HTTP requests are content-classified as third-party traffic when // they carry another agent's harness (Pi, OpenCode, and others). @@ -1162,6 +1167,7 @@ func (s *Server) forward(ctx context.Context, p provider.Provider, to provider.P // forwardOnce is one request to the provider, as forward makes it. func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provider.Protocol, path string, body []byte, in http.Header) (*http.Response, error) { + ctx = p.Via(ctx) if to == provider.Anthropic { body = s.bodyBetas(p, body) } diff --git a/internal/gateway/proxy_test.go b/internal/gateway/proxy_test.go new file mode 100644 index 00000000..e5808be4 --- /dev/null +++ b/internal/gateway/proxy_test.go @@ -0,0 +1,176 @@ +package gateway + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/yetone/magpie/internal/netproxy" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" +) + +// seen records the requests a server was sent: through a proxy, the host +// asked for (the request's absolute URL), and the path. +type seen struct { + mu sync.Mutex + name string + got []string +} + +func (s *seen) ServeHTTP(w http.ResponseWriter, r *http.Request) { + io.ReadAll(r.Body) + host := r.URL.Host // set only on a request sent to a proxy + if host == "" { + host = r.Host + } + s.mu.Lock() + s.got = append(s.got, host+r.URL.Path) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/models") { + io.WriteString(w, `{"object":"list","data":[{"id":"m","object":"model"}]}`) + return + } + io.WriteString(w, `{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"from `+s.name+`"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`) +} + +func (s *seen) list() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.got...) +} + +func (s *seen) reset() { + s.mu.Lock() + s.got = nil + s.mu.Unlock() +} + +// Each provider's requests go through its own proxy (#237): A's through the +// one it names, B's through none though a global one is set, C's, which +// follows the global one, through that — the gateway's turns and the model +// lists fetched for them alike. +func TestProviderProxy(t *testing.T) { + fresh(t) + for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} { + t.Setenv(k, "") + } + own, global, direct := &seen{name: "own proxy"}, &seen{name: "global proxy"}, &seen{name: "vendor"} + ownProxy, globalProxy, vendor := httptest.NewServer(own), httptest.NewServer(global), httptest.NewServer(direct) + t.Cleanup(ownProxy.Close) + t.Cleanup(globalProxy.Close) + t.Cleanup(vendor.Close) + + // the vendors are at names no proxy stands in front of (loopback never + // goes through one): dialled directly, every *.test is the vendor + var d net.Dialer + dial := func(ctx context.Context, network, addr string) (net.Conn, error) { + if host, _, _ := net.SplitHostPort(addr); strings.HasSuffix(host, ".test") { + addr = vendor.Listener.Addr().String() + } + return d.DialContext(ctx, network, addr) + } + transport := func() *http.Transport { + return &http.Transport{Proxy: netproxy.Func, DialContext: dial, ResponseHeaderTimeout: 10 * time.Second} + } + defaultTransport := http.DefaultClient.Transport + http.DefaultClient.Transport = netproxy.Dispatch(transport()) + t.Cleanup(func() { http.DefaultClient.Transport = defaultTransport }) + + st := settings.Load() + st.Proxy = globalProxy.URL + if err := settings.Save(st); err != nil { + t.Fatal(err) + } + for _, p := range []provider.Provider{ + {ID: "a", Name: "A", Key: "ka", Models: []string{"m"}, Chat: "http://a.test/v1", Proxy: ownProxy.URL}, + {ID: "b", Name: "B", Key: "kb", Models: []string{"m"}, Chat: "http://b.test/v1", Proxy: "direct"}, + {ID: "c", Name: "C", Key: "kc", Models: []string{"m"}, Chat: "http://c.test/v1"}, + } { + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + } + if err := provider.Save(provider.Provider{ID: "d", Name: "D", Key: "kd", Chat: "http://d.test/v1", Proxy: "ftp://x"}); err == nil { + t.Fatal("an ftp:// proxy was taken") + } + if pa, _ := provider.Find("a"); pa.Proxy != ownProxy.URL { + t.Fatalf("a's proxy isn't kept: %q", pa.Proxy) + } + if pc, _ := provider.Find("c"); pc.Proxy != "" { + t.Fatalf("c follows the global proxy, not %q", pc.Proxy) + } + + s := New() + s.client = &http.Client{Transport: netproxy.Dispatch(transport())} + check := func(what string, got *seen, want string, others ...*seen) { + t.Helper() + if l := got.list(); len(l) != 1 || l[0] != want { + t.Fatalf("%s: %v, want [%s]", what, l, want) + } + for _, o := range others { + if l := o.list(); len(l) != 0 { + t.Fatalf("%s: %s was sent %v", what, o.name, l) + } + } + for _, x := range []*seen{own, global, direct} { + x.reset() + } + } + for _, c := range []struct { + model string + by *seen + want string + not []*seen + }{ + {"a/m", own, "a.test/v1/chat/completions", []*seen{global, direct}}, + {"b/m", direct, "b.test/v1/chat/completions", []*seen{own, global}}, + {"c/m", global, "c.test/v1/chat/completions", []*seen{own, direct}}, + } { + code, body := postAs(t, s, "", `{"model":"`+c.model+`","messages":[{"role":"user","content":"hi"}]}`) + if code != 200 || !strings.Contains(body, "from "+c.by.name) { + t.Fatalf("%s: %d %s", c.model, code, body) + } + check(c.model, c.by, c.want, c.not...) + } + + // the model lists fetched for them go the same ways + ctx := context.Background() + for _, c := range []struct { + id string + by *seen + want string + not []*seen + }{ + {"a", own, "a.test/v1/models", []*seen{global, direct}}, + {"b", direct, "b.test/v1/models", []*seen{own, global}}, + {"c", global, "c.test/v1/models", []*seen{own, direct}}, + } { + p, err := provider.Find(c.id) + if err != nil { + t.Fatal(err) + } + if _, err := p.Fetch(ctx); err != nil { + t.Fatalf("%s's models: %v", c.id, err) + } + check(c.id+"'s models", c.by, c.want, c.not...) + } + + // back to following the global proxy, A's turns go through it + pa, _ := provider.Find("a") + pa.Proxy = "" + if err := provider.Save(*pa); err != nil { + t.Fatal(err) + } + if code, body := postAs(t, s, "", `{"model":"a/m","messages":[{"role":"user","content":"hi"}]}`); code != 200 || !strings.Contains(body, "from global proxy") { + t.Fatalf("a following the global proxy: %d %s", code, body) + } + check("a following the global proxy", global, "a.test/v1/chat/completions", own, direct) +} diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 79a36594..ba058863 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1371,6 +1371,12 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .editor .ehead .note { font-size: 12px; color: var(--muted); } .editor label { font-size: 12px; color: var(--muted); text-align: right; padding-top: 5px; } .editor .hint { font-size: 11px; color: var(--faint); margin-top: 4px; } +/* a provider's own proxy (#237): the address beside the options, its room + kept while it is hidden so a pick never changes the dialog's height */ +.editor .proxy-row { display: flex; align-items: center; gap: 8px; min-width: 0; } +.editor .proxy-row .proxy-mode { flex: none; } +.editor .proxy-row input.proxy-url { flex: 1 1 auto; width: auto; min-width: 0; } +.editor .proxy-row input.proxy-url.off { visibility: hidden; } /* what a balance token's URL wants instead, and a balance checked in the form */ .editor .bal-fix { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 6px; font-size: 12px; line-height: 1.45; color: var(--muted); } .editor .bal-fix:empty { display: none; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 3b01f169..fa8ef10e 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2546,6 +2546,49 @@ function input(value, placeholder, type = "text") { } function cancelEdit() { editing = null; draft = null; importing = null; importingApps = null; renderProviders(); } +// proxyPicker: the proxy one provider's requests go through (#237) — the +// one in Settings, none, or its own — so Codex can go through a proxy +// while a vendor at home goes direct. The draft keeps the choice as +// proxyMode ("" global, "direct", "custom") and the address as proxyURL; +// proxyOfDraft is what is saved. +const PROXY_HINT = { + "": "Follows the proxy in Settings", + direct: "Requests to it go direct, whatever the proxy in Settings", + custom: "Requests to it go through this proxy: http://, https:// or socks5://", +}; +function proxyPicker() { + const box = el("div", "stack proxy-pick"); + const hint = el("div", "hint", t(PROXY_HINT[draft.proxyMode || ""])); + const addr = input(draft.proxyURL || "", "http://127.0.0.1:7890"); + addr.className = "proxy-url"; + addr.classList.toggle("off", draft.proxyMode !== "custom"); + addr.oninput = () => { draft.proxyURL = addr.value; }; + const seg = segs([["", t("Global proxy")], ["direct", t("Direct")], ["custom", t("Custom")]], draft.proxyMode || "", (v) => { + draft.proxyMode = v; + addr.classList.toggle("off", v !== "custom"); + hint.textContent = t(PROXY_HINT[v]); + }); + seg.classList.add("proxy-mode"); + // the address sits beside the options, its room kept while it is not + // asked for, so picking one never changes the dialog's height (a + // centred dialog would move under the pointer) + const row = el("div", "proxy-row"); + row.append(seg, addr); + box.append(row, hint); + return box; +} +function proxyDraft(p) { + const v = (p?.proxy || "").trim(); + return { proxyMode: !v ? "" : v === "direct" ? "direct" : "custom", proxyURL: v && v !== "direct" ? v : "" }; +} +// proxyOfDraft is the draft's proxy as it is saved, or null when Custom +// has no address yet. +function proxyOfDraft() { + if (draft.proxyMode === "direct") return "direct"; + if (draft.proxyMode !== "custom") return ""; + return (draft.proxyURL || "").trim() || null; +} + // Custom request headers: the draft keeps them as an ordered [name, value, // json?] list so a half-typed row (and its open JSON editor) survives a // re-render; headersOf folds that back into the object the backend stores, @@ -2900,7 +2943,7 @@ function renderEditor(p, presetID) { // more provider of it, under a name and id of its own const another = isNew && !!pr?.added; draft = draft || (p - ? { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : "openai", chosen: p.models.filter((m) => m.on).map((m) => m.id), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), keysUrl: p.keysUrl || "" } + ? { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : "openai", chosen: p.models.filter((m) => m.on).map((m) => m.id), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), keysUrl: p.keysUrl || "", ...proxyDraft(p) } : pr ? { id: pr.id, name: pr.name, preset: pr.id, key: "", chosen: [], extra: [], headers: [] } : { id: "", name: "", preset: "", chat: "", responses: "", anthropic: "", catalog: "", key: "", api: "openai", chosen: [], extra: [], headers: [], icon: "" }); @@ -3047,6 +3090,7 @@ function renderEditor(p, presetID) { const cx = input(draft.contexts || "", t("e.g. 128k · or gpt-6=1m, comma separated")); ed.append(...field(t("Context window"), contextPicks(p, cx), t("How long a request the models take, told to the agents; empty leaves it to the vendor and models.dev"))); ed.append(...field(t("Fallback"), renderFallback(p), fallbackHint(p))); + ed.append(...field(t("Proxy"), proxyPicker())); if (p.chat || p.responses || p.anthropic) ed.append(...field(t("Endpoints"), renderEndpoints(p, p))); const bar = el("div", "bar"); // removing only hides it from magpie; the agent stays signed in @@ -3060,7 +3104,9 @@ function renderEditor(p, presetID) { saveBtn.onclick = () => { 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"); - saveBtn.classList.add("busy"); providerAction("save", { id: p.id, models: chosenIds(), unlisted: draft.unlisted, fallback: draft.fallback, contexts: cx.map }, t("{name} saved", { name: p.name })); }; + const proxy = proxyOfDraft(); + if (proxy === null) { ed.querySelector(".proxy-url")?.focus(); return editorError(t("Proxy: type its address, like http://127.0.0.1:7890"), "warn"); } + saveBtn.classList.add("busy"); providerAction("save", { id: p.id, models: chosenIds(), unlisted: draft.unlisted, fallback: draft.fallback, contexts: cx.map, proxy }, t("{name} saved", { name: p.name })); }; bar.append(cancel, saveBtn); ed.append(bar); return ed; @@ -3107,6 +3153,7 @@ function renderEditor(p, presetID) { } else { ed.append(...field(t("Headers"), headerEditor(pr?.headerHints || []), t("Optional headers sent with every request to {p}, applied after auth.", { p: pr?.name || p?.name }))); } + ed.append(...field(t("Proxy"), proxyPicker())); // 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 @@ -3300,6 +3347,8 @@ function renderEditor(p, presetID) { 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; + body.proxy = proxyOfDraft(); + if (body.proxy === null) { ed.querySelector(".proxy-url")?.focus(); return editorError(t("Proxy: type its address, like http://127.0.0.1:7890"), "warn"); } if (draft.balanceToken) body.balanceToken = draft.balanceToken; else if (draft.clearBalanceToken) body.clearBalanceToken = true; if (isNew && custom && !body.name) { name.focus(); return editorError(t("Give it a name"), "warn"); } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 4d7b91a7..d21a6a60 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -528,6 +528,13 @@ const I18N = { "+ Add header": "+ 添加请求头", "Add the {h} header; its value is yours to fill in": "添加 {h} 请求头,值由你填写", "Optional headers sent with every request to {p}, applied after auth.": "随每个发往 {p} 的请求发送的可选请求头,在鉴权之后应用。", + // a provider's own proxy (#237) + "Global proxy": "全局代理", + "Direct": "直连", + "Follows the proxy in Settings": "跟随设置里的代理", + "Requests to it go direct, whatever the proxy in Settings": "发往它的请求直连,不走设置里的代理", + "Requests to it go through this proxy: http://, https:// or socks5://": "发往它的请求走这个代理:http://、https:// 或 socks5://", + "Proxy: type its address, like http://127.0.0.1:7890": "代理:请填写地址,例如 http://127.0.0.1:7890", "Add another {name}": "再添加一个 {name}", "One more {name} provider, with its own key, headers and models": "再添加一个 {name} 供应商,使用各自的 key、请求头和模型", "e.g. {name} · Work": "例如 {name} · Work", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 917379ed..4f0ddd55 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -50,6 +50,9 @@ type providerJSON struct { Website string `json:"website"` KeysURL string `json:"keysUrl"` Headers map[string]string `json:"headers,omitempty"` + // the proxy its requests go through: "" the global one, "direct" + // none, or an address (#237) + Proxy string `json:"proxy"` // where a custom provider's balance is asked (see provider.Balance) BalanceURL string `json:"balanceURL,omitempty"` BalancePath string `json:"balancePath,omitempty"` @@ -198,7 +201,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, Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL, - Headers: p.Headers, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL, + Proxy: p.Proxy, Headers: p.Headers, 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, Affinity: p.Affinity, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts, } @@ -444,6 +447,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) { mux.HandleFunc("POST /api/provider/{action}", func(rw http.ResponseWriter, r *http.Request) { var req struct { provider.Provider + // Proxy is the proxy its requests go through (#237), "" to + // follow the global one; a save that leaves it out keeps it + Proxy *string `json:"proxy"` // New is set by the editor's Add: the provider is one more, never // one replacing the provider that has its id or name New bool `json:"new"` @@ -507,6 +513,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) { if req.New { // a second one of a preset, or a name already in use, is // added beside the first under the next free id + if req.Proxy != nil { + in.Proxy = *req.Proxy + } id, err := provider.Add(in) if err != nil { fail(rw, err) @@ -525,6 +534,11 @@ func providerRoutes(mux *http.ServeMux, w Windows) { in.ID = req.From } old, _ = provider.Find(in.ID) + if req.Proxy != nil { + in.Proxy = *req.Proxy + } else if old != nil { + in.Proxy = old.Proxy + } if in.Key == "" && old != nil { in.Key = old.Key } diff --git a/internal/gui/tests/provider-proxy.test.cjs b/internal/gui/tests/provider-proxy.test.cjs new file mode 100644 index 00000000..4266d128 --- /dev/null +++ b/internal/gui/tests/provider-proxy.test.cjs @@ -0,0 +1,155 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A provider's own proxy (#237): its editor has a Proxy row, Global proxy | +// Direct | Custom, the address field there only for Custom; Save posts +// "" for the global one, "direct", or the address typed, and Custom with +// none typed is refused before anything is posted. A signed-in account +// (Codex here) has the row too, opening on what it has. A click on the +// row leaves the page where it is. In English and Chinese. +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 relay = { + id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example.com/v1", responses: "", anthropic: "", catalog: "", + models: [{ id: "model-a", name: "Model A", on: true }], agents: [], fallback: [], headers: {}, + key: { set: true, masked: "sk-…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", +}; +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: "http://10.0.0.2:7890", +}; + +function serve(lang, posts) { + const providers = { providers: [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: { global: "Global proxy", direct: "Direct", custom: "Custom", save: "Save", follows: "Follows the proxy in Settings", none: "Proxy: type its address, like http://127.0.0.1:7890" }, + zh: { global: "全局代理", direct: "直连", custom: "自定义", save: "保存", follows: "跟随设置里的代理", none: "代理:请填写地址,例如 http://127.0.0.1:7890" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + 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}-${name}-proxy.png`) }); + } + await browser.close(); + }); + const page = await (await browser.newContext({ viewport: { width: 900, 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 .proxy-mode").waitFor(); + return { page, errors, posts }; + }; + const mode = (page) => page.locator(".editor .proxy-mode .opt.on").textContent(); + const addr = (page) => page.locator(".editor .proxy-url"); + // a click on one of the row's options, the page left where it was + const pick = async (page, name) => { + const b = page.locator(".editor .proxy-mode .opt", { hasText: name }); + await b.scrollIntoViewIfNeeded(); + await page.waitForTimeout(200); + const before = await b.evaluate((e) => e.getBoundingClientRect().top); + await b.click(); + await page.waitForTimeout(250); + const after = await b.evaluate((e) => e.getBoundingClientRect().top); + assert(Math.abs(after - before) <= 1, `${name} moved from ${before} to ${after}`); + }; + 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}: a provider's proxy is picked and saved`, async (t) => { + const { page, errors, posts } = await open(t, "Relay"); + assert.equal(await mode(page), w.global, "a provider with none follows the global one"); + assert(await addr(page).isHidden(), "no address field for the global one"); + assert.equal(await page.locator(".editor .proxy-pick .hint").textContent(), w.follows); + + // Custom with nothing typed is refused, nothing posted + await pick(page, w.custom); + assert(await addr(page).isVisible()); + await page.locator(".editor .bar").getByRole("button", { name: w.save, exact: true }).click(); + await page.locator(".editor .editor-error").waitFor(); + assert.equal(await page.locator(".editor .editor-error").textContent(), w.none); + assert.equal(posts.length, 0); + + await addr(page).fill(" socks5://127.0.0.1:1080 "); + let saved = await save(page, posts); + assert.equal(saved.action, "save"); + assert.equal(saved.body.id, "relay"); + assert.equal(saved.body.proxy, "socks5://127.0.0.1:1080"); + + // Direct, then the global one again + await page.locator(".row.provider", { hasText: "Relay" }).click(); + await pick(page, w.direct); + assert(await addr(page).isHidden()); + saved = await save(page, posts); + assert.equal(saved.body.proxy, "direct"); + await page.locator(".row.provider", { hasText: "Relay" }).click(); + await pick(page, w.direct); + await pick(page, w.global); + saved = await save(page, posts); + assert.equal(saved.body.proxy, ""); + + const missing = await page.evaluate(() => [ + "Proxy", "Global proxy", "Direct", "Custom", "Follows the proxy in Settings", + "Requests to it go direct, whatever the proxy in Settings", + "Requests to it go through this proxy: http://, https:// or socks5://", + "Proxy: type its address, like http://127.0.0.1:7890", + ].filter((k) => !I18N.zh[k])); + assert.deepEqual(missing, [], "every string has its Chinese"); + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: a signed-in account's proxy opens as it is and is saved`, async (t) => { + const { page, errors, posts } = await open(t, "Codex"); + assert.equal(await mode(page), w.custom, "Codex's own proxy is shown"); + assert.equal(await addr(page).inputValue(), "http://10.0.0.2:7890"); + assert.equal(await page.locator(".editor .proxy-pick .opt.on").count(), 1); + await pick(page, w.direct); + const saved = await save(page, posts); + assert.equal(saved.action, "save"); + assert.equal(saved.body.id, "codex"); + assert.equal(saved.body.proxy, "direct"); + assert.deepEqual(saved.body.models, ["gpt-6"], "its picks go with it"); + const border = await page.evaluate(() => [...document.querySelectorAll(".proxy-pick, .proxy-pick *")].map((e) => getComputedStyle(e).borderLeftWidth).filter((b) => parseFloat(b) > 1)); + assert.deepEqual(border, [], "no border stripes"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/netproxy/netproxy.go b/internal/netproxy/netproxy.go index 13695a14..bdc623d4 100644 --- a/internal/netproxy/netproxy.go +++ b/internal/netproxy/netproxy.go @@ -9,6 +9,7 @@ package netproxy import ( + "context" "net" "net/http" "net/url" @@ -21,21 +22,115 @@ import ( ) // Install routes http.DefaultTransport (and so http.DefaultClient) through -// Func. +// Func, and http.DefaultClient's requests made on a provider's behalf +// (With) through a transport of that provider's proxy (Dispatch). func Install() { if t, ok := http.DefaultTransport.(*http.Transport); ok { t.Proxy = Func + http.DefaultClient.Transport = Dispatch(t) } } -// Func is an http.Transport Proxy function. +// Func is an http.Transport Proxy function: the proxy the request's +// context names (With), or else the global one. func Func(req *http.Request) (*url.URL, error) { if loopback(req.URL.Hostname()) { return nil, nil } + if c := choiceOf(req.Context()); c != "" { + return forChoice(c) + } return For(req.URL) } +type choiceKey struct{} + +// With is ctx for requests made on behalf of one provider, whose own proxy +// is choice (issue #237: Codex through a proxy, a vendor at home without): +// "" follows the global one (ctx is returned as it is), "direct" goes +// through none, anything else is the proxy's address (http://, https://, +// socks5://; host:port means http). Requests made with it through Func, +// or through a transport Dispatch made, take that proxy. +func With(ctx context.Context, choice string) context.Context { + if choice = strings.TrimSpace(choice); choice == "" { + return ctx + } + return context.WithValue(ctx, choiceKey{}, choice) +} + +func choiceOf(ctx context.Context) string { + if ctx == nil { + return "" + } + c, _ := ctx.Value(choiceKey{}).(string) + return c +} + +// forChoice is the proxy a provider's own choice names; nil for "direct". +func forChoice(c string) (*url.URL, error) { + if c == "direct" { + return nil, nil + } + return Parse(c) +} + +// Dispatch is a RoundTripper sending a request through base, unless its +// context names a provider's own proxy (With): then through a clone of +// base kept for that proxy alone. Connections are so reused among the +// requests going through one proxy and never carried to another — HTTP/2 +// ones, which a transport shares by host whatever proxy they were dialled +// through, among them. +func Dispatch(base *http.Transport) http.RoundTripper { + return &dispatch{base: base, own: map[string]*http.Transport{}} +} + +type dispatch struct { + base *http.Transport + mu sync.Mutex + own map[string]*http.Transport +} + +func (d *dispatch) RoundTrip(req *http.Request) (*http.Response, error) { + c := choiceOf(req.Context()) + if c == "" { + return d.base.RoundTrip(req) + } + return d.transport(c).RoundTrip(req) +} + +func (d *dispatch) transport(c string) *http.Transport { + d.mu.Lock() + defer d.mu.Unlock() + if t, ok := d.own[c]; ok { + return t + } + if len(d.own) >= 32 { // choices come and go as they are edited + for k, t := range d.own { + t.CloseIdleConnections() + delete(d.own, k) + } + } + t := d.base.Clone() + t.Proxy = func(req *http.Request) (*url.URL, error) { + if loopback(req.URL.Hostname()) { + return nil, nil + } + return forChoice(c) + } + d.own[c] = t + return t +} + +// CloseIdleConnections closes base's and every proxy's own. +func (d *dispatch) CloseIdleConnections() { + d.base.CloseIdleConnections() + d.mu.Lock() + defer d.mu.Unlock() + for _, t := range d.own { + t.CloseIdleConnections() + } +} + // For is the proxy a request to u goes through; nil means direct. func For(u *url.URL) (*url.URL, error) { switch s := strings.TrimSpace(settings.Load().Proxy); s { @@ -63,13 +158,21 @@ func For(u *url.URL) (*url.URL, error) { // proxy set in magpie replaces any in env and "direct" drops them; the // system's is only added where env names none. Loopback never goes // through it, since a CLI calls back to magpie there. -func Env(env []string) []string { +func Env(env []string) []string { return EnvWith("", env) } + +// EnvWith is Env for a CLI run on one provider's behalf, whose own proxy +// is choice (see With): "" is Env's. +func EnvWith(choice string, env []string) []string { if env == nil { env = os.Environ() } var p string var bypass []string - switch s := strings.TrimSpace(settings.Load().Proxy); s { + s := strings.TrimSpace(choice) + if s == "" { + s = strings.TrimSpace(settings.Load().Proxy) + } + switch s { case "direct": return withProxy(env, "", nil) case "": diff --git a/internal/netproxy/netproxy_test.go b/internal/netproxy/netproxy_test.go index baeec46f..0cfd4617 100644 --- a/internal/netproxy/netproxy_test.go +++ b/internal/netproxy/netproxy_test.go @@ -1,6 +1,7 @@ package netproxy import ( + "context" "net/http" "net/url" "path/filepath" @@ -136,3 +137,57 @@ func TestEnv(t *testing.T) { var farFuture = time.Now().Add(time.Hour) var zero time.Time + +// A provider's own proxy (#237) wins over the global one, "direct" over +// both, and each proxy is given one transport of its own, kept. +func TestWith(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + settings.Save(settings.Settings{Proxy: "127.0.0.1:6152"}) + u := &url.URL{Scheme: "https", Host: "api.openai.com"} + req := func(choice string) *http.Request { + return (&http.Request{URL: u}).WithContext(With(context.Background(), choice)) + } + if p, _ := Func(req("")); p == nil || p.String() != "http://127.0.0.1:6152" { + t.Fatalf("follow: %v", p) + } + if p, _ := Func(req("socks5://127.0.0.1:1080")); p == nil || p.String() != "socks5://127.0.0.1:1080" { + t.Fatalf("own: %v", p) + } + if p, _ := Func(req("direct")); p != nil { + t.Fatalf("direct: %v", p) + } + local := (&http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1:3425"}}).WithContext(With(context.Background(), "127.0.0.1:1")) + if p, _ := Func(local); p != nil { + t.Fatalf("loopback: %v", p) + } + + d := Dispatch(&http.Transport{Proxy: Func}).(*dispatch) + a, b := d.transport("127.0.0.1:1"), d.transport("direct") + if a == b || d.transport("127.0.0.1:1") != a || len(d.own) != 2 { + t.Fatalf("transports: %d kept", len(d.own)) + } + if p, _ := a.Proxy(req("")); p == nil || p.Host != "127.0.0.1:1" { + t.Fatalf("own transport: %v", p) + } + if p, _ := b.Proxy(req("")); p != nil { + t.Fatalf("direct transport: %v", p) + } + + get := func(env []string, k string) string { + for _, e := range env { + if key, val, _ := strings.Cut(e, "="); key == k { + return val + } + } + return "" + } + if env := EnvWith("socks5://127.0.0.1:1080", []string{"PATH=/bin"}); get(env, "HTTPS_PROXY") != "socks5://127.0.0.1:1080" { + t.Fatalf("own env: %v", env) + } + if env := EnvWith("direct", []string{"HTTPS_PROXY=http://x:1", "PATH=/bin"}); len(env) != 1 { + t.Fatalf("direct env: %v", env) + } + if env := EnvWith("", []string{"PATH=/bin"}); get(env, "HTTPS_PROXY") != "http://127.0.0.1:6152" { + t.Fatalf("follow env: %v", env) + } +} diff --git a/internal/provider/account.go b/internal/provider/account.go index 5f027891..d732cbd5 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -108,6 +108,7 @@ func (p Provider) APIs(model string) []Protocol { // Sign authenticates a request to the provider, refreshing what needs it. // Plain providers get their key; accounts get the agent's tokens. func (p Provider) Sign(ctx context.Context, req *http.Request, proto Protocol, body []byte) error { + ctx = p.Via(ctx) // a sign-in refreshed on the way goes through its proxy if p.Account != nil && p.Account.sign != nil { return p.Account.sign(ctx, req, body) } diff --git a/internal/provider/balance.go b/internal/provider/balance.go index f78f784a..fa20ded3 100644 --- a/internal/provider/balance.go +++ b/internal/provider/balance.go @@ -585,6 +585,7 @@ func (e *balanceExpr) at(path string) (any, error) { // Balance asks the vendor what is left on the provider's key in use. ok is // false when there is no way to ask it. func Balance(ctx context.Context, p Provider) (amount string, ok bool, err error) { + ctx = p.Via(ctx) src, ok := balanceSourceOf(p) if !ok || p.Account != nil || p.Key == "" { return "", false, nil diff --git a/internal/provider/codex_resets.go b/internal/provider/codex_resets.go index 11c3d062..7b9a10c2 100644 --- a/internal/provider/codex_resets.go +++ b/internal/provider/codex_resets.go @@ -134,7 +134,7 @@ func UseCodexReset(ctx context.Context, user string) (ResetOutcome, error) { if credit == "" { return ResetOutcome{Code: "no_credit"}, nil } - out, err := consumeCodexReset(ctx, base, tok, accountID, credit, newRedeemID()) + out, err := consumeCodexReset(Via(ctx, "codex"), base, tok, accountID, credit, newRedeemID()) if err != nil { return out, err } diff --git a/internal/provider/codex_warmup.go b/internal/provider/codex_warmup.go index ce31982b..a49def81 100644 --- a/internal/provider/codex_warmup.go +++ b/internal/provider/codex_warmup.go @@ -336,7 +336,7 @@ func warmCodexLogin(ctx context.Context, user string) error { if err != nil { return err } - return warmCodex(ctx, codexSign(token), model, effort) + return warmCodex(Via(ctx, "codex"), codexSign(token), model, effort) } // warmModel is the model a warm-up asks, and at what effort: the account's diff --git a/internal/provider/decide.go b/internal/provider/decide.go index 84d61f57..7043e5ac 100644 --- a/internal/provider/decide.go +++ b/internal/provider/decide.go @@ -104,6 +104,7 @@ func (p Provider) vercelTypeSafe() string { // AI's is under an account: the one its base names, else the one the // token belongs to, looked up once. func (p Provider) DecideURL(ctx context.Context) (string, error) { + ctx = p.Via(ctx) switch p.DecideVia() { case ViaVercel: return p.vercelTypeSafe() + "/v1/systemone", nil diff --git a/internal/provider/models.go b/internal/provider/models.go index 583ca497..28870d8c 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -91,6 +91,7 @@ func (p Provider) Fetched() (time.Time, bool) { // Fetch asks the vendor which models it serves and remembers the answer. func (p Provider) Fetch(ctx context.Context) ([]catalog.Model, error) { + ctx = p.Via(ctx) if p.Decides() { return p.fetchDecide(ctx) } diff --git a/internal/provider/planquota.go b/internal/provider/planquota.go index 66f8843b..c87a2c71 100644 --- a/internal/provider/planquota.go +++ b/internal/provider/planquota.go @@ -353,7 +353,7 @@ func PlanQuotas(ctx context.Context) []SubscriptionQuota { go func() { defer wg.Done() q := SubscriptionQuota{Provider: j.p.ID, Name: j.p.Name, Icon: j.p.Icon, User: j.user, Windows: []QuotaWindow{}} - plan, ws, err := planWindows(ctx, j.src, j.key) + plan, ws, err := planWindows(j.p.Via(ctx), j.src, j.key) switch { case err != nil && !j.src.sure, err == nil && len(ws) == 0: return // a key with no plan diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 14f26b8e..57b69d2d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" ) // Protocol is a wire API magpie can speak to an upstream. @@ -93,6 +94,13 @@ type Provider struct { // ignore them: their auth is the agent's own. Headers map[string]string `json:"headers,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), + // "direct" none, anything else the proxy's address (http://, https://, + // socks5://; host:port means http). Signed-in accounts keep it too. + Proxy string `json:"proxy,omitempty"` + // BalanceURL, when set, is where the vendor tells what is left on a // key, asked with the key the way a chat request carries it; BalancePath // picks the amount out of the JSON reply (see balance.go). The vendors @@ -216,6 +224,7 @@ func All() []Provider { } pk := picks[a.ID] a.Models, a.Unlisted, a.Off, a.Fallback, a.Routing, a.Affinity, a.Contexts, a.Family = pk.Models, pk.Unlisted, pk.Off, pk.Fallback, pk.Routing, pk.Affinity, pk.Contexts, pk.Family + a.Proxy = pk.Proxy if a.ID == "cursor" { // picked before its efforts were one model a.Models = cursorPicks(a.Models) } @@ -295,6 +304,9 @@ func Save(p Provider) error { if p.ID == strings.TrimSuffix(GroupPrefix, "/") { return errors.New(`"group" starts the ids of routing groups; pick another id`) } + if err := settings.CheckProxy(p.Proxy); err != nil { + return err + } if p.Name == "" { p.Name = p.ID } @@ -308,7 +320,7 @@ func Save(p Provider) error { if p.ID == "kiro" { key = p.Key } - p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, Contexts: p.Contexts, Family: p.Family, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID)} + p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID)} } else { if slices.Contains(accountIDs, p.ID) && !stored(p.ID) { // taken, it would hide that subscription once signed in @@ -505,6 +517,7 @@ func normalize(p Provider) Provider { p.ID = strings.ToLower(strings.TrimSpace(p.ID)) p.Name = strings.TrimSpace(p.Name) p.Key = strings.TrimSpace(p.Key) + p.Proxy = strings.TrimSpace(p.Proxy) for _, u := range []*string{&p.Chat, &p.Responses, &p.Anthropic, &p.Decide, &p.Website, &p.KeysURL} { *u = strings.TrimRight(strings.TrimSpace(*u), "/") if *u != "" && !strings.Contains(*u, "://") { diff --git a/internal/provider/proxy.go b/internal/provider/proxy.go new file mode 100644 index 00000000..322f6c82 --- /dev/null +++ b/internal/provider/proxy.go @@ -0,0 +1,30 @@ +package provider + +import ( + "context" + + "github.com/yetone/magpie/internal/netproxy" +) + +// Via is ctx for requests made on the provider's behalf: they go through +// its own proxy (Proxy), or the global one when it has none. +func (p Provider) Via(ctx context.Context) context.Context { + return netproxy.With(ctx, p.Proxy) +} + +// ProxyOf is the own proxy of the provider or signed-in account with id, +// "" when it follows the global one. +func ProxyOf(id string) string { + for _, p := range load().Providers { + if p.ID == id { + return p.Proxy + } + } + return "" +} + +// Via is ctx for requests made on behalf of the provider or signed-in +// account with id (see Provider.Via). +func Via(ctx context.Context, id string) context.Context { + return netproxy.With(ctx, ProxyOf(id)) +} diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 6ff3a7d9..963abbcd 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -15,6 +15,8 @@ import ( "strings" "sync" "time" + + "github.com/yetone/magpie/internal/netproxy" ) // QuotaWindow is one rolling allowance reported by a subscription provider. @@ -184,8 +186,14 @@ func chosenWindows(ws []QuotaWindow, chosen map[string]bool, base func(string) s // fetchSubscriptionUsage asks every signed-in vendor at once. func fetchSubscriptionUsage() []SubscriptionQuota { - ctx, cancel := context.WithTimeout(context.Background(), subscriptionTimeout) + ctx0, cancel := context.WithTimeout(context.Background(), subscriptionTimeout) defer cancel() + // each account asked through its own proxy, if it has one (#237) + proxies := map[string]string{} + for _, p := range load().Providers { + proxies[p.ID] = p.Proxy + } + via := func(id string) context.Context { return netproxy.With(ctx0, proxies[id]) } hidden := map[string]bool{} for _, p := range load().Providers { hidden[p.ID] = p.Hidden || p.Off // switched off: not asked either @@ -193,24 +201,24 @@ func fetchSubscriptionUsage() []SubscriptionQuota { var fetches []func() SubscriptionQuota if p, ok := claudeAccount(); ok && !hidden["claude"] { if ls := accountsOf("claude"); len(ls) > 1 { - fetches = append(fetches, perLogin(ctx, ls, "Claude Code", "claude-color")...) + fetches = append(fetches, perLogin(via("claude"), ls, "Claude Code", "claude-color")...) } else { - fetches = append(fetches, withUser(p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(ctx) })) + fetches = append(fetches, withUser(p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(via("claude")) })) } } if user, plan, ok := cursorIdentity(); ok && !hidden["cursor"] { - fetches = append(fetches, withUser(user, func() SubscriptionQuota { return cursorSubscriptionUsage(ctx, plan) })) + fetches = append(fetches, withUser(user, func() SubscriptionQuota { return cursorSubscriptionUsage(via("cursor"), plan) })) } if _, ok := grokAccount(); ok && !hidden["grok"] { - fetches = append(fetches, func() SubscriptionQuota { return grokSubscriptionUsage(ctx) }) + fetches = append(fetches, func() SubscriptionQuota { return grokSubscriptionUsage(via("grok")) }) } if home, err := os.UserHomeDir(); err == nil { if p, ok := codexAccount(home); ok && !hidden["codex"] { if ls := accountsOf("codex"); len(ls) > 1 { - fetches = append(fetches, perLogin(ctx, ls, "Codex", "codex-color")...) + fetches = append(fetches, perLogin(via("codex"), ls, "Codex", "codex-color")...) } else { auth := filepath.Join(home, ".codex", "auth.json") - fetches = append(fetches, withUser(p.Account.User, func() SubscriptionQuota { return codexSubscriptionUsage(ctx, auth) })) + fetches = append(fetches, withUser(p.Account.User, func() SubscriptionQuota { return codexSubscriptionUsage(via("codex"), auth) })) } } cfg := os.Getenv("XDG_CONFIG_HOME") @@ -219,30 +227,30 @@ func fetchSubscriptionUsage() []SubscriptionQuota { } if app, ok := copilotLogin(cfg); ok && !hidden["copilot"] { if ls := copilotLoginList(); len(ls) > 1 { - fetches = append(fetches, perLogin(ctx, ls, "Copilot", "githubcopilot")...) + fetches = append(fetches, perLogin(via("copilot"), ls, "Copilot", "githubcopilot")...) } else { - fetches = append(fetches, withUser(app.User, func() SubscriptionQuota { return copilotSubscriptionUsage(ctx, app.Token) })) + fetches = append(fetches, withUser(app.User, func() SubscriptionQuota { return copilotSubscriptionUsage(via("copilot"), app.Token) })) } } } if key := kiroKey(); key != "" && !hidden["kiro"] { - fetches = append(fetches, func() SubscriptionQuota { return kiroQuotaAt(ctx, key, "") }) + fetches = append(fetches, func() SubscriptionQuota { return kiroQuotaAt(via("kiro"), key, "") }) } else if !hidden["kiro"] { - fetches = append(fetches, perLogin(ctx, kiroLoginList(), "Kiro", "kiro-color")...) + fetches = append(fetches, perLogin(via("kiro"), kiroLoginList(), "Kiro", "kiro-color")...) } if !hidden["zcode"] { - fetches = append(fetches, perLogin(ctx, zcodeLoginList(), "ZCode", "zcode")...) + fetches = append(fetches, perLogin(via("zcode"), zcodeLoginList(), "ZCode", "zcode")...) } for _, w := range []*wbSite{wbCN, wbAI} { if !hidden[w.id] { - fetches = append(fetches, perLogin(ctx, wbLoginList(w), w.name, "workbuddy-color")...) + fetches = append(fetches, perLogin(via(w.id), wbLoginList(w), w.name, "workbuddy-color")...) } } if !hidden[CommandCodePlanID] { - fetches = append(fetches, perLogin(ctx, cmdLoginList(), "Command Code", "commandcode")...) + fetches = append(fetches, perLogin(via(CommandCodePlanID), cmdLoginList(), "Command Code", "commandcode")...) } if !hidden["qoder"] { - fetches = append(fetches, perLogin(ctx, loginsOf(qoderLogins()), "Qoder", "qoder")...) + fetches = append(fetches, perLogin(via("qoder"), loginsOf(qoderLogins()), "Qoder", "qoder")...) } if !hidden["dimagent"] { fetches = append(fetches, perLogin(ctx, dimagentLoginList(), "DimAgent", "dimagent")...) @@ -252,7 +260,7 @@ func fetchSubscriptionUsage() []SubscriptionQuota { continue } for _, l := range googleLogins(agent) { - fetches = append(fetches, func() SubscriptionQuota { return l.acct.quota(ctx, l.Plan) }) + fetches = append(fetches, func() SubscriptionQuota { return l.acct.quota(via(agent), l.Plan) }) } } out := make([]SubscriptionQuota, len(fetches)) diff --git a/internal/provider/test.go b/internal/provider/test.go index ab26e16e..a161775c 100644 --- a/internal/provider/test.go +++ b/internal/provider/test.go @@ -32,6 +32,7 @@ type Result struct { // serves, signed with a key made for it and asking for a model that key // sees, and reports what came back. func (p Provider) Test(ctx context.Context) []Result { + ctx = p.Via(ctx) if p.Decides() { return p.testDecide(ctx) } @@ -105,6 +106,7 @@ func tinyDrawing(q Provider, model string) (url, body string) { // on the endpoint it's served on and with a key that sees it: whether each // answers, not only whether the vendor does. Results are in models' order. func (p Provider) TestModels(ctx context.Context, models []string) []Result { + ctx = p.Via(ctx) out := make([]Result, len(models)) sem := make(chan struct{}, 4) var wg sync.WaitGroup diff --git a/internal/settings/settings.go b/internal/settings/settings.go index eae26f72..2a082830 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -193,6 +193,26 @@ func Load() Settings { return s.normal() } +// CheckProxy says whether p is a proxy setting magpie takes: "" (follow), +// "direct", or an http://, https:// or socks5:// address (host:port +// meaning http). The global Proxy and a provider's own are both checked +// with it. +func CheckProxy(p string) error { + p = strings.TrimSpace(p) + if p == "" || p == "direct" { + return nil + } + raw := p + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || !slices.Contains([]string{"http", "https", "socks5", "socks5h"}, u.Scheme) { + return fmt.Errorf("proxy must look like http://127.0.0.1:7890 or socks5://127.0.0.1:1080, not %q", p) + } + return nil +} + // Save validates and writes the settings. func Save(s Settings) error { s = s.normal() @@ -223,15 +243,8 @@ func Save(s Settings) error { return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery) } s.Proxy = strings.TrimSpace(s.Proxy) - if s.Proxy != "" && s.Proxy != "direct" { - raw := s.Proxy - if !strings.Contains(raw, "://") { - raw = "http://" + raw - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" || !slices.Contains([]string{"http", "https", "socks5", "socks5h"}, u.Scheme) { - return fmt.Errorf("proxy must look like http://127.0.0.1:7890 or socks5://127.0.0.1:1080, not %q", s.Proxy) - } + if err := CheckProxy(s.Proxy); err != nil { + return err } s.Vision = strings.TrimSpace(s.Vision) if s.Vision != "" && s.Vision != "off" && !strings.Contains(s.Vision, "/") {