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:
15 files changed
+486
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "://") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user