providers: a Remote magpie provider uses another computer's shared magpie, each request translated at most once (Jorben on Discord: 远程 magpie / magpie 接力) — a custom provider pointed at another magpie has one API per address and couldn't know which API each model's own provider serves, so a request was translated on both computers (Chat → Messages here, then again there). magpie's /v1/models now tells each model's native_endpoints (the APIs its provider serves it on; none for a routing group) and its image input, and reads them, with max_output_tokens and supported_reasoning_levels, from a list; the new remote-magpie preset takes the other magpie's address however typed (host:port, /v1, /v1/messages) for all three APIs and lists all its models, its user's choice already. Add sheet tile with the address field and a hint to Settings → Share on local network; CLI: magpie provider add remote-magpie <key> url=http://<ip>:3425. Checked with two sandbox magpies over the LAN address with the key check on and a Messages-only fake upstream: Chat, Messages (streamed), count_tokens, Responses, a routing group and Gemini all reached it as /v1/messages, translated once. TestRemoteMagpieNativeAPI, TestFetchMagpieFields and TestRemoteMagpie fail without the change; remote-magpie.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass but for TestCursorPluginContext, which fails on main (1ce05f0) as well; click-scroll, old-webkit pass.

This commit is contained in:
yetone committed 2026-10-01 01:35:18 +08:00
1 parent f1299714bf
commit ba3c26dec4
15 files changed
+486 -5

No files matched your search

+13 -1
View File
@@ -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
+34 -1
View File
@@ -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 —
+33
View File
@@ -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
+32
View File
@@ -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))
+132
View File
@@ -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
}
+1 -1
View File
@@ -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 }));
+3
View File
@@ -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}",
+9 -1
View File
@@ -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
+117
View File
@@ -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();
});
}
});
}
+2 -1
View File
@@ -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
+11
View File
@@ -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,
+1
View File
@@ -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, "://") {
+49
View File
@@ -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 <address>/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
}
+46
View File
@@ -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)
}
}
+3
View File
@@ -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'