From ba3c26dec45f1da0ee97a0189f4c3b232b734fd0 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 01:26:45 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20a=20Remote=20magpie=20provider=20u?= =?UTF-8?q?ses=20another=20computer's=20shared=20magpie,=20each=20request?= =?UTF-8?q?=20translated=20at=20most=20once=20(Jorben=20on=20Discord:=20?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=20magpie=20/=20magpie=20=E6=8E=A5=E5=8A=9B)?= =?UTF-8?q?=20=E2=80=94=20a=20custom=20provider=20pointed=20at=20another?= =?UTF-8?q?=20magpie=20has=20one=20API=20per=20address=20and=20couldn't=20?= =?UTF-8?q?know=20which=20API=20each=20model's=20own=20provider=20serves,?= =?UTF-8?q?=20so=20a=20request=20was=20translated=20on=20both=20computers?= =?UTF-8?q?=20(Chat=20=E2=86=92=20Messages=20here,=20then=20again=20there)?= =?UTF-8?q?.=20magpie's=20/v1/models=20now=20tells=20each=20model's=20nati?= =?UTF-8?q?ve=5Fendpoints=20(the=20APIs=20its=20provider=20serves=20it=20o?= =?UTF-8?q?n;=20none=20for=20a=20routing=20group)=20and=20its=20image=20in?= =?UTF-8?q?put,=20and=20reads=20them,=20with=20max=5Foutput=5Ftokens=20and?= =?UTF-8?q?=20supported=5Freasoning=5Flevels,=20from=20a=20list;=20the=20n?= =?UTF-8?q?ew=20remote-magpie=20preset=20takes=20the=20other=20magpie's=20?= =?UTF-8?q?address=20however=20typed=20(host:port,=20/v1,=20/v1/messages)?= =?UTF-8?q?=20for=20all=20three=20APIs=20and=20lists=20all=20its=20models,?= =?UTF-8?q?=20its=20user's=20choice=20already.=20Add=20sheet=20tile=20with?= =?UTF-8?q?=20the=20address=20field=20and=20a=20hint=20to=20Settings=20?= =?UTF-8?q?=E2=86=92=20Share=20on=20local=20network;=20CLI:=20magpie=20pro?= =?UTF-8?q?vider=20add=20remote-magpie=20=20url=3Dhttp://:3425.?= =?UTF-8?q?=20Checked=20with=20two=20sandbox=20magpies=20over=20the=20LAN?= =?UTF-8?q?=20address=20with=20the=20key=20check=20on=20and=20a=20Messages?= =?UTF-8?q?-only=20fake=20upstream:=20Chat,=20Messages=20(streamed),=20cou?= =?UTF-8?q?nt=5Ftokens,=20Responses,=20a=20routing=20group=20and=20Gemini?= =?UTF-8?q?=20all=20reached=20it=20as=20/v1/messages,=20translated=20once.?= =?UTF-8?q?=20TestRemoteMagpieNativeAPI,=20TestFetchMagpieFields=20and=20T?= =?UTF-8?q?estRemoteMagpie=20fail=20without=20the=20change;=20remote-magpi?= =?UTF-8?q?e.test.cjs=20(Chromium,=20WebKit,=20en/zh)=20added.=20go=20vet,?= =?UTF-8?q?=20go=20build=20-tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOO?= =?UTF-8?q?S=3Dwindows=20go=20build,=20go=20test=20-tags=20nogui=20./...?= =?UTF-8?q?=20pass=20but=20for=20TestCursorPluginContext,=20which=20fails?= =?UTF-8?q?=20on=20main=20(1ce05f0)=20as=20well;=20click-scroll,=20old-web?= =?UTF-8?q?kit=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++- internal/catalog/live.go | 35 +++++- internal/catalog/live_test.go | 33 ++++++ internal/gateway/gateway.go | 32 ++++++ internal/gateway/remote_magpie_test.go | 132 ++++++++++++++++++++++ internal/gui/assets/app.js | 2 +- internal/gui/assets/i18n.js | 3 + internal/gui/tests/README.md | 10 +- internal/gui/tests/remote-magpie.test.cjs | 117 +++++++++++++++++++ internal/provider/models.go | 3 +- internal/provider/presets.go | 11 ++ internal/provider/provider.go | 1 + internal/provider/remote_magpie.go | 49 ++++++++ internal/provider/remote_magpie_test.go | 46 ++++++++ providers_cli.go | 3 + 15 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 internal/gateway/remote_magpie_test.go create mode 100644 internal/gui/tests/remote-magpie.test.cjs create mode 100644 internal/provider/remote_magpie.go create mode 100644 internal/provider/remote_magpie_test.go diff --git a/README.md b/README.md index 962907be..00511499 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,16 @@ separate Responses endpoint, `catalog=` to borrow a models.dev list, and `models=` to name the models to expose. Anything a preset does not know can be overridden the same way. +One magpie can serve several computers (an office one, a personal one): +share it on the network (Settings → Share on local network), and on each +other computer add it as a **Remote magpie** — in the app's Add sheet, or +`magpie provider add remote-magpie sk-magpie-… url=http://192.168.1.20:3425 id=office`. +Each computer's own magpie still wires its agents, while the providers, +routing groups (`office/group/…`) and usage are the shared one's. A request +goes on in the API the agent spoke — Anthropic Messages, Responses, Chat +Completions, token counting — and a model the shared magpie's provider serves +on another API only is turned into that API once, never on both computers. + Baidu Qianfan's [Token Plans](https://cloud.baidu.com/doc/qianfan/s/Dmrabu8b6) are available as `baidu-qianfan`: a personal (个人版) and an enterprise (企业版) plan and pay as you go, each with its own Chat Completions, Responses and @@ -296,7 +306,9 @@ with the app; `magpie serve` runs it alone. It exposes: Each `/v1/models` entry includes `reasoning` and `supported_reasoning_levels` (`[{"effort":"low"}, ...]`). A routing group lists only the levels every -member supports. +member supports. `native_endpoints` (`["/v1/messages"]`) names the APIs a +request for the model is passed straight through on; it is left out of a +routing group, and of a model every request to is translated anyway. Requests pass straight through when the vendor speaks the agent's API and are translated otherwise, streaming, tool calls and reasoning included. The diff --git a/internal/catalog/live.go b/internal/catalog/live.go index 25b92182..044eb306 100644 --- a/internal/catalog/live.go +++ b/internal/catalog/live.go @@ -274,10 +274,18 @@ func fetchOne(ctx context.Context, url, key string, anthropic bool, headers map[ name = id } input := imageInput(r.Modalities.Input) - m := Model{ID: id, Name: name, ImageInput: input, APIs: EndpointAPIs(r.Endpoints), Draws: drawer} + apis := EndpointAPIs(r.Endpoints) + if native := EndpointAPIs(r.Native); len(native) > 0 { + apis = native + } + m := Model{ID: id, Name: name, ImageInput: input, APIs: apis, Draws: drawer} if n, ok := r.ContextLength.(float64); ok && n > 0 { m.Context = int(n) } + if n, ok := r.Output.(float64); ok && n > 0 { + m.Output = int(n) + } + m.Efforts = levelsOf(r.Levels) if input != nil { m.Images = *input } @@ -327,6 +335,31 @@ type liveModel struct { // the context window, where the list tells it (OpenRouter, Command // Code); any, as a vendor's odd value mustn't lose the whole list ContextLength any `json:"context_length"` + // what another magpie's list tells of each model: the APIs its own + // provider serves it on, where a request goes on as it is rather + // than translated (native_endpoints), its longest reply and its + // reasoning levels. any, as a vendor's odd value mustn't lose the + // whole list + Native []string `json:"native_endpoints"` + Output any `json:"max_output_tokens"` + Levels any `json:"supported_reasoning_levels"` +} + +// levelsOf are the efforts of a list's supported_reasoning_levels, as +// magpie and Codex write them ([{"effort":"high"}]) or as plain names. +func levelsOf(v any) []string { + xs, _ := v.([]any) + var out []string + for _, x := range xs { + e, _ := x.(string) + if o, ok := x.(map[string]any); ok { + e, _ = o["effort"].(string) + } + if e != "" && !slices.Contains(out, e) { + out = append(out, e) + } + } + return out } // EndpointAPIs names the APIs of a model list's supported_endpoints — diff --git a/internal/catalog/live_test.go b/internal/catalog/live_test.go index 5edfdf57..a3cd2aa5 100644 --- a/internal/catalog/live_test.go +++ b/internal/catalog/live_test.go @@ -2,6 +2,8 @@ package catalog import ( "context" + "fmt" + "maps" "net/http" "net/http/httptest" "strings" @@ -45,6 +47,37 @@ func TestFetchContextLength(t *testing.T) { } } +// Another magpie's list tells each model's native APIs, over the ones it +// serves it on, its longest reply and its reasoning levels; a vendor's odd +// values are only left out, never the whole list. +func TestFetchMagpieFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Write([]byte(`{"object":"list","data":[ + {"id":"anthropic/claude-opus-4-8","native_endpoints":["/v1/messages"],"max_output_tokens":128000,"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}]}, + {"id":"group/fast","supported_reasoning_levels":[]}, + {"id":"codex/gpt-6","supported_endpoints":["/responses","/chat/completions"],"supported_reasoning_levels":["low","medium"]}, + {"id":"odd","max_output_tokens":"lots","supported_reasoning_levels":{"low":true}}]}`)) + })) + defer srv.Close() + ms, _, err := FetchAt(context.Background(), srv.URL+"/v1", "k", false, nil) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, m := range ms { + got[m.ID] = fmt.Sprint(m.APIs, m.Output, m.Efforts) + } + want := map[string]string{ + "anthropic/claude-opus-4-8": "[anthropic] 128000 [low high]", + "group/fast": "[] 0 []", + "codex/gpt-6": "[responses chat] 0 [low medium]", + "odd": "[] 0 []", + } + if !maps.Equal(got, want) { + t.Errorf("got %v", got) + } +} + // A base with a version in its path (Ark's /api/plan/v3, tcdw's report) is // asked for its list only as written: no /v1 is put on it, and a failure // names the URL asked and what the vendor said. A base without one is diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index fa5262d6..bc4a0be5 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -383,9 +383,41 @@ func modelObject(e provider.Entry) map[string]any { if e.Output > 0 { m["max_output_tokens"] = e.Output } + // for another magpie that has this one as its provider (remote-magpie): + // the APIs a request for the model goes on as it is, so it sends each + // one on an API of these rather than having it translated twice, and + // whether it takes images + if native := nativeEndpoints(e); len(native) > 0 { + m["native_endpoints"] = native + } + if e.Images { + m["modalities"] = map[string]any{"input": []string{"text", "image"}} + } else if e.ImageInput != nil { + m["modalities"] = map[string]any{"input": []string{"text"}} + } return m } +// nativeEndpoints are the paths a request for the model is relayed on to +// its provider as it is: the APIs the provider serves it on. None for a +// routing group, whose members may speak any, or a model every request +// to is translated anyway (a subscription served through its agent's own +// API). +func nativeEndpoints(e provider.Entry) []string { + p := e.Provider + if e.Group != "" || p.Native(e.Model) == "" { + return nil + } + apis := p.APIs(e.Model) + var out []string + for _, pr := range p.Speaks() { + if slices.Contains(provider.Protocols, pr) && (apis == nil || slices.Contains(apis, pr)) { + out = append(out, map[provider.Protocol]string{provider.Chat: "/v1/chat/completions", provider.Responses: "/v1/responses", provider.Anthropic: "/v1/messages"}[pr]) + } + } + return out +} + // catalogFor is the catalog as the agent asking is shown it. func catalogFor(r *http.Request) []provider.Entry { shown, _ := provider.CatalogFor(agentOf(r)) diff --git a/internal/gateway/remote_magpie_test.go b/internal/gateway/remote_magpie_test.go new file mode 100644 index 00000000..c3c0cb57 --- /dev/null +++ b/internal/gateway/remote_magpie_test.go @@ -0,0 +1,132 @@ +package gateway + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// A remote magpie (another computer's gateway as a provider) is asked in +// the API the model's own provider speaks there: a Chat client's request +// for a model served on Anthropic's Messages goes to it as Messages, not as +// Chat to be translated on the other side too; a routing group's, which +// could be any member, goes on in the client's own API. Here the gateway is +// both computers': "office" is it, reached over HTTP, and the vendor behind +// it speaks Anthropic's Messages alone. +func TestRemoteMagpieNativeAPI(t *testing.T) { + var mu sync.Mutex + var vendor, hops []string // paths the vendor, and the remote magpie, were asked on + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + vendor = append(vendor, r.URL.Path+" "+gjsonModel(b)) + mu.Unlock() + if strings.HasSuffix(r.URL.Path, "/count_tokens") { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"input_tokens":42}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse( + `event: message_start`+"\n"+`data: {"type":"message_start","message":{"id":"msg_1","model":"m1","usage":{"input_tokens":7}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`, + `data: {"type":"message_stop"}`)) + })) + t.Cleanup(up.Close) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := provider.Save(provider.Provider{ID: "fake", Name: "Fake", Key: "k", Models: []string{"m1"}, Anthropic: up.URL}); err != nil { + t.Fatal(err) + } + if err := provider.SaveGroup(provider.Group{ID: "g", Name: "G", Members: []string{"fake/m1"}}); err != nil { + t.Fatal(err) + } + h := New().Handler() + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + mu.Lock() + hops = append(hops, r.URL.Path) + mu.Unlock() + } + h.ServeHTTP(w, r) + })) + t.Cleanup(remote.Close) + + // added as the Add sheet does: the address alone, on the Chat field + id, err := provider.Add(provider.Provider{ID: "office", Name: "Office", Preset: provider.RemoteMagpiePreset, Chat: strings.TrimPrefix(remote.URL, "http://")}) + if err != nil { + t.Fatal(err) + } + office, err := provider.Find(id) + if err != nil { + t.Fatal(err) + } + if office.Chat != remote.URL+"/v1" || office.Responses != remote.URL+"/v1" || office.Anthropic != remote.URL { + t.Fatalf("endpoints: %q %q %q", office.Chat, office.Responses, office.Anthropic) + } + if _, err := office.Fetch(context.Background()); err != nil { + t.Fatal(err) + } + live, _, _ := catalog.Live("office") + apis := map[string][]string{} + for _, m := range live { + apis[m.ID] = m.APIs + } + if !slices.Equal(apis["fake/m1"], []string{"anthropic"}) { + t.Fatalf("fake/m1 is served on Messages there: %v", apis) + } + if a, ok := apis["group/g"]; !ok || a != nil { + t.Fatalf("the group is listed, on no one API: %v", apis) + } + + calls := []struct{ path, body, hop, vendor string }{ + // Chat client, a model served on Messages: turned into Messages here, once + {"/v1/chat/completions", `{"model":"office/fake/m1","messages":[{"role":"user","content":"hi"}]}`, "/v1/messages", "/v1/messages m1"}, + // Anthropic client: Messages all the way + {"/v1/messages", `{"model":"office/fake/m1","max_tokens":10,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, "/v1/messages", "/v1/messages m1"}, + {"/v1/messages/count_tokens", `{"model":"office/fake/m1","messages":[{"role":"user","content":"hi"}]}`, "/v1/messages/count_tokens", "/v1/messages/count_tokens m1"}, + // a group's request goes on in the client's API; the remote picks + {"/v1/responses", `{"model":"office/group/g","input":"hi","stream":true}`, "/v1/responses", "/v1/messages m1"}, + } + for _, c := range calls { + mu.Lock() + vendor, hops = nil, nil + mu.Unlock() + code, body := post(t, c.path, c.body) + mu.Lock() + gotHops, gotVendor := slices.Clone(hops), slices.Clone(vendor) + mu.Unlock() + if code != 200 { + t.Fatalf("%s: %d %s", c.path, code, body) + } + if !slices.Equal(gotHops, []string{c.hop}) || !slices.Equal(gotVendor, []string{c.vendor}) { + t.Errorf("%s: remote asked on %v, vendor on %v; want %s, %s", c.path, gotHops, gotVendor, c.hop, c.vendor) + } + if strings.HasSuffix(c.path, "count_tokens") && !strings.Contains(body, `"input_tokens":42`) { + t.Errorf("count: %s", body) + } + if !strings.HasSuffix(c.path, "count_tokens") && !strings.Contains(body, "hello") { + t.Errorf("%s reply: %s", c.path, body) + } + } +} + +func gjsonModel(b []byte) string { + var v struct { + Model string `json:"model"` + } + json.Unmarshal(b, &v) + return v.Model +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 95c75293..c1f21a38 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -4031,7 +4031,7 @@ function drawEditor(p, presetID) { } if (isNew && custom && !body.name) { name.focus(); return editorError(t("Give it a name"), "warn"); } if (isNew && custom && !body.chat && !body.anthropic && !body.responses) { url.focus(); return editorError(t("A base URL is needed"), "warn"); } - if (endpoint && !body.chat && !body.responses) { endpoint.focus(); return editorError(t("Your resource's endpoint is needed"), "warn"); } + if (endpoint && !body.chat && !body.responses) { endpoint.focus(); return editorError(t(pr.endpointNeeded || "Your resource's endpoint is needed"), "warn"); } editorError(""); saveBtn.classList.add("busy"); providerAction("save", body, t(isNew ? "{name} added" : "{name} saved", { name: draft.name || draft.id })); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 7c95f7ea..15a4af97 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -640,6 +640,9 @@ const I18N = { "Your resource's endpoint is needed": "需要填写你的资源终结点", "your resource's endpoint and key": "你的资源终结点和密钥", "Your resource's endpoint, from Keys and Endpoint in the Azure portal. magpie asks its v1 API; the model ids are your deployments' names.": "你的资源终结点,见 Azure 门户的「密钥和终结点」。magpie 调用它的 v1 API;模型 ID 就是你的部署名称。", + "another computer's magpie, shared on its network": "另一台电脑上的 magpie,局域网共享", + "The address and API key the other computer's magpie shows in Settings, under Share on local network. Its models and routing groups are listed here; each request goes on in the API the agent spoke.": "另一台电脑的 magpie 在「设置 → 局域网共享」里显示的地址和 API 密钥。它的模型和路由组会列在这里;每个请求按智能体所用的 API 原样转发过去。", + "The other magpie's address is needed": "需要填写另一台 magpie 的地址", "Test": "测试", "Send a tiny request through each endpoint": "向每个端点发送一个小请求", "model {model}": "模型 {model}", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 12111ac6..bffc90cb 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -491,10 +491,18 @@ off. Neither click moves the page. In the tray panel, Opus 4.5's slider ends at high, and a step posts that level and says the same restart. Chromium and WebKit, in English and Chinese, with `/api/plugins` faked. +`remote-magpie.test.cjs` adds a Remote magpie (another computer's magpie, +shared on its network) from the Add sheet: its tile, the address field with +its placeholder and a hint on where the other magpie shows its address and +key; saving without an address says the other magpie's address is needed, +and saving posts the `remote-magpie` preset with the key and the address as +typed. OpenAI's editor has no address field. No click moves the page; +English and Chinese, Chromium and WebKit. + With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/remote-magpie.test.cjs b/internal/gui/tests/remote-magpie.test.cjs new file mode 100644 index 00000000..39c51943 --- /dev/null +++ b/internal/gui/tests/remote-magpie.test.cjs @@ -0,0 +1,117 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Another computer's magpie is added from the Relays with its address and +// key alone: the Remote magpie editor asks for the address above the key, +// with a hint on where the other magpie shows it and that each request +// goes on in the API the agent spoke. Adding without one says so in its +// own words and sends nothing; with one, the address goes as the preset's +// endpoint, however it was typed (the backend puts every API on it). +// Nothing moves the page. English and Chinese; no backend, the API is +// faked here. +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 presets = [ + { id: "openai", name: "OpenAI", icon: "openai", kind: "vendor", chat: "https://api.openai.com/v1", added: false }, + { id: "remote-magpie", name: "Remote magpie", icon: "magpie", kind: "relay", added: false, + note: "another computer's magpie, shared on its network", endpoint: "http://192.168.1.20:3425", + endpointHint: "The address and API key the other computer's magpie shows in Settings, under Share on local network. Its models and routing groups are listed here; each request goes on in the API the agent spoke.", + endpointNeeded: "The other magpie's address is needed" }, +]; +const providers = [{ id: "openrouter", name: "OpenRouter", icon: "openai", preset: "openrouter", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }]; + +function server(lang, saves) { + 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:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/providers") return json({ providers, presets, excluded: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/provider/save") { + saves.push(route.request().postDataJSON()); + return json({ providers, presets, excluded: [], gateway: { running: true, window: true } }); + } + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/groups") return json({ groups: [], pools: [] }); + if (url.pathname === "/api/gateway/trace") return json({ routes: [] }); + 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)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +const L = { + en: { field: "Endpoint", hint: /Share on local network.*the API the agent spoke/, needed: "The other magpie's address is needed", note: "another computer's magpie, shared on its network", add: "Add" }, + zh: { field: "终结点", hint: /设置 → 局域网共享.*原样转发/, needed: "需要填写另一台 magpie 的地址", note: "另一台电脑上的 magpie,局域网共享", add: "添加" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": Remote magpie asks for the other magpie's address", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + for (const lang of ["en", "zh"]) { + await t.test(lang, async () => { + const w = L[lang]; + const saves = []; + const page = await (await browser.newContext({ viewport: { width: 900, height: 700 } })).newPage(); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, saves)); + await page.goto("http://magpie.test/?view=providers"); + await page.locator("#addProvider").click(); + const sheet = page.locator("#addSheet"); + const tile = sheet.locator(".tile", { has: page.locator(".n", { hasText: /^Remote magpie$/ }) }); + assert.equal(await tile.getAttribute("title"), w.note); + await tile.click(); + const ed = page.locator(".editor.new"); + await ed.locator(".ehead b", { hasText: "Remote magpie" }).waitFor(); + + // the field, above the key, focused first; the OpenAI preset has none + const label = ed.locator("label", { hasText: new RegExp("^" + w.field + "$") }); + assert.equal(await label.count(), 1); + const endpoint = ed.locator("input.endpoint"); + assert.equal(await endpoint.getAttribute("placeholder"), "http://192.168.1.20:3425"); + assert.equal(await endpoint.getAttribute("type"), "url"); + assert.match(await endpoint.locator("xpath=following-sibling::div[contains(@class,'hint')]").textContent(), w.hint); + await page.waitForFunction(() => document.activeElement?.classList.contains("endpoint")); + const key = ed.locator("input[type=password]").first(); + const [ey, ky] = [await endpoint.boundingBox(), await key.boundingBox()].map((b) => b.y); + assert(ey < ky, "the endpoint above the key"); + + // no endpoint: said, nothing sent, the page where it was + await key.fill("sk-magpie-x"); + const y = await page.evaluate(() => document.scrollingElement.scrollTop); + await ed.locator(".bar button.primary", { hasText: w.add }).click(); + assert.equal(await ed.locator(".editor-error").textContent(), w.needed); + assert.equal(saves.length, 0); + assert.equal(await page.evaluate(() => document.activeElement?.classList.contains("endpoint")), true); + assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), y); + + // with one: sent as the preset's, with the key + await endpoint.fill("192.168.1.20:3425"); + await ed.locator(".bar button.primary", { hasText: w.add }).click(); + await page.waitForFunction(() => !document.querySelector(".editor.new")); + assert.equal(saves.length, 1); + const s = saves[0]; + assert.equal(s.preset, "remote-magpie"); + assert.equal(s.key, "sk-magpie-x"); + assert.equal(s.chat, "192.168.1.20:3425"); + + // another preset's editor has no endpoint field + await page.locator("#addProvider").click(); + await sheet.locator(".tile .n", { hasText: /^OpenAI$/ }).click(); + await ed.locator(".ehead b", { hasText: "OpenAI" }).waitFor(); + assert.equal(await ed.locator("input.endpoint").count(), 0); + assert.deepEqual(errors, []); + await page.context().close(); + }); + } + }); +} diff --git a/internal/provider/models.go b/internal/provider/models.go index c8db7148..5e6e2612 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -413,7 +413,8 @@ func (p Provider) Exposed() []catalog.Model { if len(p.Models) > 0 { return pick(p.Models) } - if len(avail) <= manyModels { + // another magpie's list is already the models its user exposed + if len(avail) <= manyModels || p.IsRemoteMagpie() { return avail } // More than an agent's picker wants. Show the first slice of the diff --git a/internal/provider/presets.go b/internal/provider/presets.go index a12fe5cf..91992c6a 100644 --- a/internal/provider/presets.go +++ b/internal/provider/presets.go @@ -48,6 +48,8 @@ type PresetDef struct { // EndpointHint under it. Endpoint string `json:"endpoint,omitempty"` EndpointHint string `json:"endpointHint,omitempty"` + // EndpointNeeded is what the editor says when no endpoint was given. + EndpointNeeded string `json:"endpointNeeded,omitempty"` // Hosts: a vendor serving other makers' models as well as its own // (Groq, Ollama Cloud), whose list is no maker's word on theirs Hosts bool `json:"-"` @@ -338,6 +340,15 @@ var presets = []PresetDef{ {ID: "cn", Name: "China Mainland", Chat: "https://cn.yylx.io/v1", Anthropic: "https://cn.yylx.io"}, }}, + // another computer's magpie, shared on its network (remote_magpie.go): + // its providers, routing groups and usage stay there, each request + // goes on in the API the agent spoke + {ID: RemoteMagpiePreset, Name: "Remote magpie", Icon: "magpie", Kind: KindRelay, + Note: "another computer's magpie, shared on its network", + Endpoint: "http://192.168.1.20:3425", + EndpointHint: "The address and API key the other computer's magpie shows in Settings, under Share on local network. Its models and routing groups are listed here; each request goes on in the API the agent spoke.", + EndpointNeeded: "The other magpie's address is needed"}, + // Jev answers no conversation: it decides which of a routing group's // models takes a turn, and how hard it thinks {ID: "typesafe", Name: "TypeSafe Jev", Icon: "typesafe", Kind: KindVendor, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 46946dd0..72ccfb4b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -589,6 +589,7 @@ func normalize(p Provider) Provider { p.Proxy = strings.TrimSpace(p.Proxy) p.AccountProxies = normalAccountProxies(p.AccountProxies) p.ZhipuTeam = p.ZhipuTeam.normal() + p.remoteMagpieEndpoints() 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/remote_magpie.go b/internal/provider/remote_magpie.go new file mode 100644 index 00000000..a4fd49d1 --- /dev/null +++ b/internal/provider/remote_magpie.go @@ -0,0 +1,49 @@ +package provider + +import "strings" + +// A remote magpie is another computer's magpie gateway, shared on its +// network (Settings → Share on local network), as a provider: the +// computers' agents stay wired by their own magpie, while the providers, +// routing groups and usage are the one magpie's they all share. Its +// address serves every API magpie does, so the provider speaks them all +// — Chat Completions and Responses at
/v1, Anthropic's Messages +// at the address — and a request goes on in the API the agent spoke. Its +// model list tells, of each model, the APIs its own provider serves it on +// (native_endpoints), and a request for one goes on one of those: turned +// into another API once, here, when it must be, never on both computers. + +// RemoteMagpiePreset is the preset's id. +const RemoteMagpiePreset = "remote-magpie" + +// IsRemoteMagpie reports whether the provider is another magpie. +func (p Provider) IsRemoteMagpie() bool { return p.Preset == RemoteMagpiePreset } + +// remoteMagpieEndpoints puts every API on the address given, however it +// was typed: the bare host and port (plain http, which magpie serves), +// …/v1, or the Anthropic root. +func (p *Provider) remoteMagpieEndpoints() { + if !p.IsRemoteMagpie() { + return + } + root := "" + for _, u := range []string{p.Chat, p.Responses, p.Anthropic} { + if u = strings.TrimRight(strings.TrimSpace(u), "/"); u != "" { + root = u + break + } + } + if root == "" { + return + } + if !strings.Contains(root, "://") { + root = "http://" + root + } + for _, suf := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1"} { + if b, ok := strings.CutSuffix(root, suf); ok { + root = b + break + } + } + p.Chat, p.Responses, p.Anthropic = root+"/v1", root+"/v1", root +} diff --git a/internal/provider/remote_magpie_test.go b/internal/provider/remote_magpie_test.go new file mode 100644 index 00000000..5a91ff7a --- /dev/null +++ b/internal/provider/remote_magpie_test.go @@ -0,0 +1,46 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// Another magpie is added by its address however it was typed, and +// speaks every API there: Chat and Responses at /v1, Messages at the root. +// Its whole list is offered, being already what its user exposed. +func TestRemoteMagpie(t *testing.T) { + azureHome(t) + for _, in := range []string{"192.168.1.20:3425", "http://192.168.1.20:3425/", "http://192.168.1.20:3425/v1", "http://192.168.1.20:3425/v1/messages"} { + p := normalize(Provider{Preset: RemoteMagpiePreset, Chat: in}) + if p.Chat != "http://192.168.1.20:3425/v1" || p.Responses != p.Chat || p.Anthropic != "http://192.168.1.20:3425" { + t.Errorf("%q: %q %q %q", in, p.Chat, p.Responses, p.Anthropic) + } + } + // a custom provider's URL is left as it was + if p := normalize(Provider{Chat: "192.168.1.20:3425/v1"}); p.Anthropic != "" || p.Chat != "https://192.168.1.20:3425/v1" { + t.Errorf("custom: %+v", p) + } + + if err := Save(Provider{ID: "office", Preset: RemoteMagpiePreset, Key: "sk-magpie-x", Chat: "http://192.168.1.20:3425"}); err != nil { + t.Fatal(err) + } + var ms []catalog.Model + for i := range manyModels + 6 { + ms = append(ms, catalog.Model{ID: fmt.Sprintf("p/m%d", i)}) + } + if err := catalog.SaveLive("office", "http://192.168.1.20:3425/v1", ms); err != nil { + t.Fatal(err) + } + p, err := Find("office") + if err != nil { + t.Fatal(err) + } + if n := len(p.Exposed()); n != len(ms) { + t.Errorf("exposed %d of %d", n, len(ms)) + } + if !p.Ready() || p.Icon != "magpie" { + t.Errorf("%+v", p) + } +} diff --git a/providers_cli.go b/providers_cli.go index c6ffcef5..67e96eb8 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -43,6 +43,9 @@ const providerUsage = `usage: e.g. magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… magpie provider add "Own Claude" anthropic=https://gw.example.com key=sk-… catalog=anthropic magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… header.X-Org-Id=acme + magpie provider add remote-magpie sk-magpie-… url=http://192.168.1.20:3425 id=office + (another computer's magpie, shared on its network: its models and routing + groups as office/…, each request sent on in the API the agent spoke) magpie provider add anthropic sk-… id=anthropic-ws2 name="Anthropic WS2" header.anthropic-workspace-id=wrkspc_… magpie provider set my-relay models.url=https://relay.example.com/api/models catalog= magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… balance=https://relay.example.com/api/usage/token balance.path='$data.total_available / 500000'