providers: each provider, a signed-in account like Codex's among them, goes through its own proxy — the one in Settings, none, or its own address (#237 xosi: 目前似乎是全局设置代理的,codex和国模供应商无法单独区分设置代理) — magpie had one proxy, Settings' (or the environment's, or the system's), for every request it made, so Codex, which needs a proxy from China, and a vendor at home, which is slower or refused through one, couldn't both be right. A provider now has a proxy, "" (following the global one, as before and the default), "direct" (none, whatever Settings says) or an http://, https:// or socks5:// address (host:port meaning http), kept as proxy in providers.json only when set, and for an account provider in its picks entry beside its models. It is checked on save by the same rule Settings' proxy is (settings.CheckProxy, now shared), so ftp:// and the like are refused. netproxy.With puts a provider's choice on a request's context and netproxy.Func honours it; netproxy.Dispatch sends such a request through a clone of the transport kept for that proxy alone (at most 32, cleared past that), since a transport shares an HTTP/2 connection by host whatever proxy dialled it; loopback is never proxied. The gateway's client and http.DefaultClient go through Dispatch, and every request made on a provider's behalf carries its choice: the gateway's turns (attempt, forward, draw, decide, the Codex backend), and the model lists, balances, plan and subscription usage, tests, sign-in, Codex's warm-up and reset, while the Claude subscription's and warm-up's CLI runs get the account's proxy in their *_PROXY (netproxy.EnvWith). The provider editor, a key provider's and a signed-in account's alike, has a Proxy row after Fallback: Global proxy | Direct | Custom (全局代理 | 直连 | 自定义), the address field beside the options only for Custom, its room kept otherwise so a pick never changes the centred dialog's height and moves nothing, and a line under it saying what the pick does; Save posts proxy ("" , "direct" or the trimmed address), a save without it keeps the old one, and Custom with no address is refused before anything is posted ("Proxy: type its address, like http://127.0.0.1:7890"). No border stripes; en and zh strings together. Tested with a new internal/gateway/proxy_test.go in a sandbox HOME: with a global proxy set, provider A's turns and model list go through its own fake proxy, B's (direct) straight to the vendor, C's (following) through the global one, none through another, and A set back to following goes through the global one; ftp:// is refused (fails with With neutered). netproxy_test.go's TestWith covers Func, Dispatch's per-proxy transports and EnvWith. A new internal/gui/tests/provider-proxy.test.cjs in Chromium and WebKit, en and zh: a key provider opens on Global proxy with no address, Custom unfilled is refused and nothing posted, a padded socks5 address saved trimmed, Direct saved as "direct" and Global as ""; Codex's account opens on Custom with its address and saves Direct with its models; every pick leaves the option within 1px; no left border. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./..., provider-proxy, click-scroll, old-webkit, balance-fix and add-sheet pass (click-scroll's WebKit case failed once alongside the others and passes alone, twice). Not tried against a real proxy or the real Wails window.

This commit is contained in:
yetone committed 2026-09-30 02:15:50 +08:00
1 parent 9992678c64
commit 125da38943
26 files changed
+686 -42

No files matched your search

+1 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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 {
+1
View File
@@ -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
+1
View File
@@ -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
+8 -2
View File
@@ -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)
}
+176
View File
@@ -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)
}
+6
View File
@@ -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; }
+51 -2
View File
@@ -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"); }
+7
View File
@@ -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",
+15 -1
View File
@@ -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
}
+155
View File
@@ -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, []);
});
}
}
+107 -4
View File
@@ -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 "":
+55
View File
@@ -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)
}
}
+1
View File
@@ -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)
}
+1
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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)
}
+1 -1
View File
@@ -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
+14 -1
View File
@@ -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, "://") {
+30
View File
@@ -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))
}
+24 -16
View File
@@ -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))
+2
View File
@@ -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
+22 -9
View File
@@ -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, "/") {