routing: a group's member can be sent in its vendor's fast mode, beside its own reasoning (vs on Discord: a member "gpt-6.1 sol · high · fast") — a member's effort was already its own (#189, "provider/model:effort") but there was no way to ask for speed, so a group could not hold "gpt-6.1-sol at high, priority" next to slower members. A group now lists in fast the members it sends fast, as Members spells them, kept beside Members rather than in their ids so an older version still routes to them, only not fast (checked: HEAD's build on the same providers.json sends the same members at their effort, no tier, and leaves the file as it was). When the gateway sends to such a member it asks in the upstream's own words, only where the model has a fast mode magpie can ask for (provider.CanFast): service_tier "priority" for a ChatGPT account's GPT models and an OpenAI key's GPT/o-series models on its Responses API, Cursor's -fast variant, and speed "fast" with the fast-mode-2026-02-01 beta for Claude Opus 4.8/5/5.5 on an Anthropic key (api.anthropic.com); a relay, Bedrock, a Claude subscription, a model without one, or a provider since moved off those hosts is sent untouched. It is applied in the agent's own API and carried through translation (parseAnthropic now reads speed), so Chat, Responses and Anthropic agents all get it. A translated request for OpenAI's Chat API is still told no tier (TestCursorPluginFast), hence the Responses requirement. The trace marks the try, the order and the group (fast), shown as "fast" beside the reasoning in Requests and the order. Typed, a member is provider/model[:effort]:fast: magpie group add/set models=… takes it, fast=<m1>,… sets them, models-= takes either spelling, and the group lists and shows members as typed with "· fast"; a model with no fast mode, or a group in the group, is refused. The GUI group editor shows a "Standard speed"/"Fast" toggle (标准速度/快速) beside a member's reasoning only for a model that has one, the card reads "· fast" after the reasoning, and fast follows a member's reasoning change and leaves with a removed member; the TUI's group view takes E to step a member's effort and F to toggle fast. Provider renames carry it. TestFastMemberOpenAI, TestFastMemberAnthropic, TestWithFastWire (gateway, per wire format, a fake vendor recording body and anthropic-beta), TestCanFast, TestSaveGroupFast, TestGroupMembersFast, TestGroupMemberEffortAndFast, TestGroupSaveFast and group-member-fast.test.cjs fail without the change. A sandbox magpie (own HOME, gateway on 3966) with providers at http://api.openai.com and http://api.anthropic.com through a local recording proxy sent priority+high to OpenAI and speed fast + the beta to Anthropic from Chat, Responses and Anthropic clients, and nothing extra for Sonnet or a non-fast member. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; group-member-fast, click-scroll, old-webkit, routing-levels, routing-manual, routing-effort, settings-groups pass (Chromium, WebKit, en/zh).

This commit is contained in:
yetone committed 2026-10-01 17:13:34 +08:00
1 parent 258d04f569
commit 10b940233c
21 files changed
+1022 -29

No files matched your search

+48 -9
View File
@@ -31,7 +31,8 @@ const groupUsage = `usage:
it is in follow; an agent set to the old id needs setting again),
effort=auto (the classifier picks each turn's reasoning; needs classifier=),
effort=agent (the agent's again), classifier=<provider/model>|group/<id>,
pick=<model> (routing=manual, every request to that one of its models)
pick=<model> (routing=manual, every request to that one of its models),
fast=<m1>[,m2…] (the models sent in their vendor's fast mode; empty for none)
magpie group pick <id> <model> route the group manually, every request to that one of its models
(as clicking it on the group's card in the Routing view does)
magpie group rm <id> remove a group (one magpie found is hidden instead)
@@ -50,6 +51,9 @@ const groupUsage = `usage:
or max — sent whatever the agent asks or effort=auto picks, at the level the model has nearest;
without one it reasons as the group's effort says. The same model at two efforts is two members
(a rule can send to either). A model whose own id ends so (a :free, :7b or :0) stays as it is
provider/model[:effort]:fast sends the member in its vendor's fast mode: priority processing on a
ChatGPT account (Codex's Fast) or an OpenAI key, Cursor's -fast model, Claude's fast mode on an
Anthropic key for Opus 4.8 and 5; a model without one is refused (codex/gpt-6.1-sol:high:fast)
routing smart (default) of the subscriptions with quota to spare, the one renewing soonest first
order the first model until it can't answer, then the next
rotate each conversation's next turn goes to the next member's account or key
@@ -167,6 +171,15 @@ func memberResolver(keep []string) func(string) (string, error) {
if slices.Contains(keep, id) {
return id, nil
}
if member, fast := provider.MemberFast(id); fast {
// sent fast (Group.Fast): the member as any other, SaveGroup
// takes the :fast off
m, err := resolve(member)
if err != nil {
return "", err
}
return m + ":" + provider.FastWord, nil
}
if model, effort := provider.MemberEffort(id); effort != "" {
// a model at an effort of its own: the model as any other is
if strings.HasPrefix(model, provider.GroupPrefix) {
@@ -297,6 +310,7 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st
g.Name = v
case "models", "members", "model":
g.Members, err = members(v)
g.Fast = nil // the list as typed: :fast on those sent fast
case "models+", "members+", "model+":
var add []string
if add, err = members(v); err == nil {
@@ -308,7 +322,7 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st
}
case "models-", "members-", "model-":
for _, m := range splitList(v) {
m = strings.TrimPrefix(m, "magpie/")
m, _ = provider.MemberFast(strings.TrimPrefix(m, "magpie/"))
i := slices.IndexFunc(g.Members, func(x string) bool { return strings.EqualFold(x, m) })
if i < 0 { // a bare model id, as models= takes it
i = slices.IndexFunc(g.Members, func(x string) bool {
@@ -352,13 +366,25 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st
}
case "classifier", "classify":
g.Classifier = strings.TrimPrefix(strings.TrimSpace(v), "magpie/")
case "fast":
// the members sent in their vendor's fast mode, as they are in
// the group
g.Fast = nil
for _, m := range splitList(v) {
m, _ = provider.MemberFast(m)
id, perr := pickMember(g.Members, m)
if perr != nil {
return perr
}
g.Fast = append(g.Fast, id)
}
case "pick", "use":
// the one member a manual group sends to (#317): routing is
// manual then
g.Pick, err = pickMember(g.Members, v)
g.Routing = provider.Manual
default:
return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, pick, stays, context, levels, family, effort, classifier; magpie group help)", k)
return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, pick, stays, context, levels, family, effort, classifier, fast; magpie group help)", k)
}
if err != nil {
return err
@@ -677,6 +703,15 @@ func memberLabel(id string, names map[string]provider.Entry) (string, bool) {
return "", false
}
// typedMember is the group's member as it is typed: with :fast after it
// when the group sends it fast.
func typedMember(g provider.Group, id string) string {
if g.IsFast(id) {
return id + ":" + provider.FastWord
}
return id
}
func catalogByID() map[string]provider.Entry {
out := map[string]provider.Entry{}
for _, e := range provider.Served() {
@@ -734,15 +769,16 @@ func groups() error {
if served && (picked || g.Routing != provider.Manual) {
ready = true
}
shown := typedMember(g, id)
switch {
case !served:
ms = append(ms, faint.Render(id+" (not served)"))
ms = append(ms, faint.Render(shown+" (not served)"))
case picked:
ms = append(ms, green.Render("● "+id))
ms = append(ms, green.Render("● "+shown))
case g.Routing == provider.Manual:
ms = append(ms, faint.Render(id))
ms = append(ms, faint.Render(shown))
default:
ms = append(ms, id)
ms = append(ms, shown)
}
}
r.members = strings.Join(ms, sep)
@@ -799,11 +835,14 @@ func showGroup(g provider.Group) error {
if i == 0 {
k = "models"
}
line := fmt.Sprintf("%d %s", i+1, id)
line := fmt.Sprintf("%d %s", i+1, typedMember(g, id))
if g.Routing == provider.Manual && id == g.Picked() {
line = green.Render(fmt.Sprintf("%d %s ●", i+1, id))
line = green.Render(fmt.Sprintf("%d %s ●", i+1, typedMember(g, id)))
}
if l, ok := memberLabel(id, names); ok {
if g.IsFast(id) {
l += " · fast"
}
line += muted.Render(" " + l)
} else {
line = faint.Render(line) + amber.Render(" not served now, skipped")
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// A member typed with :fast after it (and its effort) is sent in its
// vendor's fast mode; fast= says which members are, models-= takes one
// out typed either way, and the group reads them back typed as they were.
func TestGroupMembersFast(t *testing.T) {
groupsHome(t)
if err := provider.Save(provider.Provider{ID: "oa", Name: "OpenAI", Key: "k", Responses: "https://api.openai.com/v1", Models: []string{"gpt-6.1-sol"}}); err != nil {
t.Fatal(err)
}
g, err := addGroup("Sol", []string{"models=gpt-6.1-sol:high:fast,a/m"})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(g.Members, []string{"oa/gpt-6.1-sol:high", "a/m"}) || !slices.Equal(g.Fast, []string{"oa/gpt-6.1-sol:high"}) {
t.Fatalf("members %v fast %v", g.Members, g.Fast)
}
out, err := said(t, func() error { return showGroup(g) })
if err != nil || !strings.Contains(out, "oa/gpt-6.1-sol:high:fast") || !strings.Contains(out, "· fast") {
t.Fatalf("shown %v:\n%s", err, out)
}
if out, _ = said(t, groups); !strings.Contains(out, "oa/gpt-6.1-sol:high:fast") {
t.Fatalf("listed:\n%s", out)
}
if g, err = setGroup("sol", []string{"fast="}); err != nil || len(g.Fast) != 0 {
t.Fatalf("fast= %v %v", g.Fast, err)
}
if g, err = setGroup("sol", []string{"fast=oa/gpt-6.1-sol:high"}); err != nil || !g.IsFast("oa/gpt-6.1-sol:high") {
t.Fatalf("fast=… %v %v", g.Fast, err)
}
if _, err = setGroup("sol", []string{"fast=a/m"}); err == nil || !strings.Contains(err.Error(), "no fast mode") {
t.Fatalf("a/m on a relay sent fast: %v", err)
}
if g, err = setGroup("sol", []string{"models-=gpt-6.1-sol:high:fast"}); err != nil || !slices.Equal(g.Members, []string{"a/m"}) || len(g.Fast) != 0 {
t.Fatalf("models-= %v %v %v", g.Members, g.Fast, err)
}
}
+2 -1
View File
@@ -75,6 +75,7 @@ type aRequest struct {
Effort string `json:"effort,omitempty"`
} `json:"output_config,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Speed string `json:"speed,omitempty"` // "fast": Claude's fast mode
}
func parseAnthropic(body []byte) (*Request, error) {
@@ -83,7 +84,7 @@ func parseAnthropic(body []byte) (*Request, error) {
return nil, fmt.Errorf("invalid request: %v", err)
}
r := &Request{Model: a.Model, System: stringOrText(a.System), MaxTokens: a.MaxTokens,
Temp: a.Temperature, TopP: a.TopP, Stop: a.StopSequences, Stream: a.Stream}
Temp: a.Temperature, TopP: a.TopP, Stop: a.StopSequences, Stream: a.Stream, Fast: a.Speed == "fast"}
if len(a.Metadata) > 0 && string(a.Metadata) != "null" {
r.Metadata = a.Metadata
}
+4 -1
View File
@@ -37,6 +37,9 @@ type candidate struct {
// effort is the reasoning the group's member it is of is fixed at
// ("provider/model:low"); "" for one that follows the agent or the group
effort string
// fast is set on a member the group sends in its vendor's fast mode
// (Group.Fast)
fast bool
}
// label names a candidate in a call's record: the provider, and the key
@@ -286,7 +289,7 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider.
// the same model at another effort is another member's
for _, l := range [][]candidate{cs, aside, left} {
for i := range l {
l[i].effort = m.Effort
l[i].effort, l[i].fast = m.Effort, m.Fast
}
}
*asides = append(*asides, aside...)
+20 -4
View File
@@ -1113,10 +1113,16 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
attemptBody, picked = b, true
}
}
// a member sent fast is, in its vendor's words, where its model
// has a fast mode; else it goes as the agent asked
fast := c.fast && provider.CanFast(c.p, c.model)
if fast {
attemptBody = withFast(from, attemptBody)
}
// the reasoning the model is asked for, whoever chose it
sent = sentEffort(from, attemptBody, c.p, c.model)
s.trace.update(tr, func(t *Route) {
t.Tries = append(t.Tries, Try{ID: c.rest, Model: c.model, Effort: sent, Picked: picked, Fixed: c.effort, Start: began})
t.Tries = append(t.Tries, Try{ID: c.rest, Model: c.model, Effort: sent, Picked: picked, Fixed: c.effort, Fast: fast, Start: began})
})
held := false // answered as its vendor did a moment ago, without asking
if said, ok := verifyHeld(c.restKey()); ok && last {
@@ -1136,7 +1142,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// the vendor's safety filter, with nothing said (#248)
call.Error = refusedError(c.p, c.model, hw.failMsg)
}
try := Try{ID: c.rest, Model: c.model, Effort: sent, Picked: picked, Fixed: c.effort, Start: began, Done: true, Status: call.Status, Millis: time.Since(began).Milliseconds(), Error: call.Error,
try := Try{ID: c.rest, Model: c.model, Effort: sent, Picked: picked, Fixed: c.effort, Fast: fast, Start: began, Done: true, Status: call.Status, Millis: time.Since(began).Milliseconds(), Error: call.Error,
Served: call.Usage.Served}
asName := provider.SentNameOnIn(wiresOf(r.Context()), c.p.ID, accountAgent(c.p), c.model, sent)
try.Swapped, try.Routed = swapped(asName, call.Usage.Served), usage.GroupRouted(asName, call.Usage.Served)
@@ -1534,7 +1540,11 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid
}
}
}
if bs := s.betas(p, in.Values("anthropic-beta")); len(bs) > 0 {
asked := in.Values("anthropic-beta")
if gjson.GetBytes(body, "speed").String() == "fast" && provider.HostOf(p.Base(to)) == "api.anthropic.com" {
asked = append(slices.Clone(asked), claudeFastBeta) // a group's member sent fast
}
if bs := s.betas(p, asked); len(bs) > 0 {
req.Header.Set("anthropic-beta", strings.Join(bs, ","))
} else {
req.Header.Del("anthropic-beta")
@@ -2365,7 +2375,13 @@ func build(proto provider.Protocol, r *Request, model, host string, rejectTemp b
case provider.Responses:
return buildResponses(r, model, host, rejectTemp)
}
return buildAnthropic(r, model)
out := buildAnthropic(r, model)
if r.Fast && host == "api.anthropic.com" && provider.ClaudeFast(model) {
// Claude's fast mode, on the models that have it (its beta header
// goes with it: forwardOnce)
out = withFields(out, map[string]any{"speed": "fast"})
}
return out
}
func decoder(proto provider.Protocol) func(data string, emit func(Event)) error {
+23
View File
@@ -0,0 +1,23 @@
package gateway
import "github.com/yetone/magpie/internal/provider"
// claudeFastBeta is the beta Claude's fast mode (speed "fast") is asked
// under.
const claudeFastBeta = "fast-mode-2026-02-01"
// withFast asks for the vendor's fast mode in the agent's own protocol, as
// a group's member sent fast is (provider.Group.Fast): priority processing
// in OpenAI's words, speed "fast" in Anthropic's. A request carried on in
// another protocol keeps it (Request.Fast), and goes out with it only
// where the vendor and model have one (buildResponses, buildChat, build,
// cursorModelID).
func withFast(proto provider.Protocol, body []byte) []byte {
switch proto {
case provider.Chat, provider.Responses:
return withFields(body, map[string]any{"service_tier": "priority"})
case provider.Anthropic:
return withFields(body, map[string]any{"speed": "fast"})
}
return body
}
+265
View File
@@ -0,0 +1,265 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// fastUp is every vendor at once: the gateway's calls to api.openai.com,
// api.anthropic.com or a relay all come here, and it keeps the last.
type fastUp struct {
mu sync.Mutex
host, path, beta, raw string
}
func (u *fastUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
u.mu.Lock()
u.path, u.beta, u.raw = r.URL.Path, r.Header.Get("anthropic-beta"), string(b)
u.mu.Unlock()
if strings.Contains(string(b), `"stream":true`) {
w.Header().Set("Content-Type", "text/event-stream")
switch {
case strings.HasSuffix(r.URL.Path, "/messages"):
io.WriteString(w, sse(
`event: message_start`+"\n"+`data: {"type":"message_start","message":{"id":"m1","model":"x","usage":{"input_tokens":1}}}`,
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`,
`data: {"type":"content_block_stop","index":0}`,
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`,
`data: {"type":"message_stop"}`))
case strings.HasSuffix(r.URL.Path, "/responses"):
io.WriteString(w, sse(
`event: response.created`+"\n"+`data: {"type":"response.created","response":{"id":"r1","model":"x","status":"in_progress","output":[]}}`,
`event: response.output_text.delta`+"\n"+`data: {"type":"response.output_text.delta","item_id":"o1","output_index":0,"content_index":0,"delta":"ok"}`,
`event: response.completed`+"\n"+`data: {"type":"response.completed","response":{"id":"r1","model":"x","status":"completed","output":[{"type":"message","id":"o1","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`))
default:
io.WriteString(w, sse(
`data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}`,
`data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
`data: [DONE]`))
}
return
}
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/messages"):
io.WriteString(w, `{"id":"m1","type":"message","role":"assistant","model":"x","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)
case strings.HasSuffix(r.URL.Path, "/responses"):
io.WriteString(w, `{"id":"r1","object":"response","status":"completed","model":"x","output":[{"type":"message","id":"o1","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)
default:
io.WriteString(w, `{"id":"c1","object":"chat.completion","model":"x","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
}
}
func (u *fastUp) last(t *testing.T) (host, path, beta string, body map[string]any) {
t.Helper()
u.mu.Lock()
defer u.mu.Unlock()
if err := json.Unmarshal([]byte(u.raw), &body); err != nil {
t.Fatalf("%v: %q", err, u.raw)
}
return u.host, u.path, u.beta, body
}
// fasted is a gateway whose OpenAI key (oa), Anthropic key (an) and relay
// (rl) all answer from one fake vendor, which sees the host each call was
// for.
func fasted(t *testing.T) (*Server, *fastUp) {
t.Helper()
fresh(t)
up := &fastUp{}
srv := httptest.NewServer(up)
t.Cleanup(srv.Close)
to, _ := url.Parse(srv.URL)
for _, p := range []provider.Provider{
{ID: "oa", Name: "OpenAI", Key: "ko", Chat: "https://api.openai.com/v1", Responses: "https://api.openai.com/v1", Models: []string{"gpt-6.1-sol", "gpt-6.1-mini"}},
{ID: "an", Name: "Anthropic", Key: "ka", Anthropic: "https://api.anthropic.com", Models: []string{"claude-opus-5-5", "claude-sonnet-5"}},
{ID: "rl", Name: "Relay", Key: "kr", Chat: "https://relay.example/v1", Models: []string{"gpt-6.1-sol"}},
} {
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
}
s := New()
s.client = &http.Client{Transport: qoderRoundTrip(func(r *http.Request) (*http.Response, error) {
up.mu.Lock()
up.host = r.URL.Host
up.mu.Unlock()
r.URL.Scheme, r.URL.Host = to.Scheme, to.Host
return http.DefaultTransport.RoundTrip(r)
})}
return s, up
}
func saveFastGroup(t *testing.T, members, fast []string) {
t.Helper()
if err := provider.SaveGroup(provider.Group{ID: "f", Name: "F", Members: members, Fast: fast, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
}
func postProto(t *testing.T, s *Server, path, body string) Route {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
if strings.HasSuffix(path, "/messages") {
req.Header.Set("anthropic-version", "2023-06-01")
}
s.Handler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body)
}
return lastRoute(s)
}
var fastAsks = []struct{ name, path, body string }{
{"chat", "/v1/chat/completions", `{"model":"group/f","messages":[{"role":"user","content":"hi"}]}`},
{"responses", "/v1/responses", `{"model":"group/f","input":"hi"}`},
{"anthropic", "/v1/messages", `{"model":"group/f","max_tokens":32000,"messages":[{"role":"user","content":"hi"}]}`},
}
// A group's member sent fast on an OpenAI key is asked for priority
// processing (service_tier "priority") at its own effort, whichever API
// the agent spoke — Chat, Responses or Anthropic's — and the trace says so.
func TestFastMemberOpenAI(t *testing.T) {
s, up := fasted(t)
if err := provider.SetModelEfforts("oa/gpt-6.1-sol", []string{"low", "medium", "high", "xhigh"}); err != nil {
t.Fatal(err)
}
saveFastGroup(t, []string{"oa/gpt-6.1-sol:high:fast", "rl/gpt-6.1-sol"}, nil)
g, _, _ := provider.FindGroup("group/f")
if g.Members[0] != "oa/gpt-6.1-sol:high" || len(g.Fast) != 1 || g.Fast[0] != "oa/gpt-6.1-sol:high" {
t.Fatalf("saved %v fast %v", g.Members, g.Fast)
}
for _, a := range fastAsks {
r := postProto(t, s, a.path, a.body)
host, path, _, body := up.last(t)
if host != "api.openai.com" || body["service_tier"] != "priority" || body["model"] != "gpt-6.1-sol" {
t.Fatalf("%s: sent to %s%s %v", a.name, host, path, body)
}
effort := body["reasoning_effort"]
if rs, ok := body["reasoning"].(map[string]any); ok {
effort = rs["effort"]
}
if effort != "high" {
t.Errorf("%s: effort %v in %v", a.name, effort, body)
}
if len(r.Tries) != 1 || !r.Tries[0].Fast || r.Tries[0].Fixed != "high" || !r.Order[0].Fast {
t.Fatalf("%s: traced %+v %+v", a.name, r.Tries, r.Order)
}
if r.Group == nil || len(r.Group.Fast) != 1 || r.Group.Fast[0] != "oa/gpt-6.1-sol:high" {
t.Fatalf("%s: group %+v", a.name, r.Group)
}
}
}
// Claude's fast mode on an Anthropic key: speed "fast" and its beta, as
// Anthropic's API has the agent's request or one translated to it.
func TestFastMemberAnthropic(t *testing.T) {
s, up := fasted(t)
saveFastGroup(t, []string{"an/claude-opus-5-5"}, []string{"an/claude-opus-5-5"})
for _, a := range fastAsks {
r := postProto(t, s, a.path, a.body)
host, path, beta, body := up.last(t)
if host != "api.anthropic.com" || path != "/v1/messages" || body["speed"] != "fast" || !strings.Contains(beta, claudeFastBeta) {
t.Fatalf("%s: sent to %s%s beta %q %v", a.name, host, path, beta, body)
}
if _, ok := body["service_tier"]; ok {
t.Errorf("%s: OpenAI's tier sent to Anthropic: %v", a.name, body)
}
if !r.Tries[0].Fast {
t.Errorf("%s: traced %+v", a.name, r.Tries)
}
}
}
// A member not sent fast, and one whose vendor has no fast mode magpie can
// ask for (a relay; Claude Sonnet), goes as the agent asked: no tier, no
// speed, no beta.
func TestFastMemberLeftAlone(t *testing.T) {
s, up := fasted(t)
check := func(what string) {
t.Helper()
for _, a := range fastAsks {
r := postProto(t, s, a.path, a.body)
_, _, beta, body := up.last(t)
if _, ok := body["service_tier"]; ok {
t.Errorf("%s %s: tier sent %v", what, a.name, body)
}
if _, ok := body["speed"]; ok || strings.Contains(beta, claudeFastBeta) {
t.Errorf("%s %s: speed sent %v %q", what, a.name, body, beta)
}
if r.Tries[0].Fast {
t.Errorf("%s %s: traced fast", what, a.name)
}
}
}
saveFastGroup(t, []string{"oa/gpt-6.1-sol", "an/claude-opus-5-5"}, nil)
check("not fast")
saveFastGroup(t, []string{"an/claude-opus-5-5"}, nil)
check("opus not fast")
// fast saved while it was OpenAI's, the provider's address since moved
// to a relay: the member stays, sent as asked
saveFastGroup(t, []string{"oa/gpt-6.1-sol:fast"}, nil)
p, _ := provider.Find("oa")
p.Chat, p.Responses = "https://relay.example/v1", ""
if err := provider.Save(*p); err != nil {
t.Fatal(err)
}
check("moved to a relay")
for _, m := range []string{"rl/gpt-6.1-sol:fast", "an/claude-sonnet-5:fast"} {
if err := provider.SaveGroup(provider.Group{ID: "g", Members: []string{m}}); err == nil || !strings.Contains(err.Error(), "no fast mode") {
t.Errorf("%s saved: %v", m, err)
}
}
}
// The rewrite in each wire format, and only where the vendor has it.
func TestWithFastWire(t *testing.T) {
for proto, want := range map[string]string{"chat": `"service_tier":"priority"`, "responses": `"service_tier":"priority"`, "anthropic": `"speed":"fast"`} {
var p provider.Protocol
switch proto {
case "chat":
p = provider.Chat
case "responses":
p = provider.Responses
default:
p = provider.Anthropic
}
if got := string(withFast(p, []byte(`{"model":"m"}`))); !strings.Contains(got, want) {
t.Errorf("%s: %s", proto, got)
}
}
r, err := parseAnthropic([]byte(`{"model":"m","max_tokens":10,"speed":"fast","messages":[{"role":"user","content":"hi"}]}`))
if err != nil || !r.Fast {
t.Fatalf("speed not read: %v %+v", err, r)
}
for _, c := range []struct {
host, model string
want bool
}{
{"api.anthropic.com", "claude-opus-5-5", true},
{"api.anthropic.com", "claude-opus-4-8-20260101", true},
{"api.anthropic.com", "claude-sonnet-5", false},
{"relay.example", "claude-opus-5-5", false},
} {
got := strings.Contains(string(build(provider.Anthropic, r, c.model, c.host, false)), `"speed":"fast"`)
if got != c.want {
t.Errorf("%s %s: speed %v, want %v", c.host, c.model, got, c.want)
}
}
for host, want := range map[string]bool{"api.openai.com": true, "chatgpt.com": true, "relay.example": false} {
if got := strings.Contains(string(buildResponses(r, "gpt-6.1-sol", host, false)), `"service_tier":"priority"`); got != want {
t.Errorf("responses %s: tier %v, want %v", host, got, want)
}
}
}
+8 -1
View File
@@ -74,6 +74,8 @@ type GroupRef struct {
// Via: for each of Members, the groups in the group it is of, as
// "fast>cheap" ("" for the group's own), when it has groups in it
Via []string `json:"via,omitempty"`
// Fast: those of Members sent in their vendor's fast mode
Fast []string `json:"fast,omitempty"`
}
// SubGroup is a routing group in the group a request asked for.
@@ -98,6 +100,9 @@ func groupRef(g provider.Group, ms []provider.Member) *GroupRef {
seen := map[string]bool{}
for _, m := range ms {
ref.Members = append(ref.Members, provider.WithMemberEffort(m.Provider.ID+"/"+m.Model, m.Effort))
if m.Fast {
ref.Fast = append(ref.Fast, ref.Members[len(ref.Members)-1])
}
ref.Via = append(ref.Via, strings.Join(m.Groups(), ">"))
in := g.ID
for _, v := range m.Via {
@@ -127,6 +132,7 @@ type Weighed struct {
Plan string `json:"plan,omitempty"`
Model string `json:"model"`
Fixed string `json:"fixed,omitempty"` // the effort the group's member it is of is fixed at
Fast bool `json:"fast,omitempty"` // the group's member it is of is sent fast
Routing string `json:"routing"` // its provider's: "", order, rotate, usage
Fallback bool `json:"fallback,omitempty"`
Shared bool `json:"shared,omitempty"` // its provider has more than one on
@@ -157,6 +163,7 @@ type Try struct {
// Fixed: the effort the group's member it went to is fixed at, which
// Effort is (fitted to the model's levels) whatever was asked
Fixed string `json:"fixed,omitempty"`
Fast bool `json:"fast,omitempty"` // sent in its vendor's fast mode, as the group's member it went to is
Start time.Time `json:"start"`
Done bool `json:"done"`
Status int `json:"status,omitempty"`
@@ -194,7 +201,7 @@ type planned struct {
}
func weighed(c candidate, p provider.Provider, wg weighing, fallback bool, from provider.Protocol) Weighed {
w := Weighed{ID: c.rest, Provider: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Model: c.model, Fixed: c.effort,
w := Weighed{ID: c.rest, Provider: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Model: c.model, Fixed: c.effort, Fast: c.fast,
Routing: p.Routing, Fallback: fallback, Shared: c.rest != p.ID}
switch {
case c.p.Account != nil:
+6
View File
@@ -1343,6 +1343,12 @@ const I18N = {
"as the group's effort says": "按分组的推理强度设置",
"whatever the agent asks": "无论 Agent 要求什么",
"{name} at that reasoning is in the group already": "{name} 以这个推理强度已经在分组里了",
"fast": "快速",
"Fast": "快速",
"Standard speed": "标准速度",
"Sent in its vendor's fast mode whatever the agent asks: quicker, at a higher price": "无论 Agent 要求什么,都以服务商的快速模式发送:更快,价格更高",
"Sent at its vendor's usual speed; click to send it in fast mode": "以服务商的常规速度发送;点击改为快速模式",
"Sent in its vendor's fast mode, as the group says": "按分组设置,以服务商的快速模式发送",
"{level} reasoning: the model's nearest to the {asked} {agent} asked for": "{level} 推理强度:模型最接近 {agent} 要求的 {asked} 的档位",
"{level} reasoning, as {agent} asked": "{level} 推理强度,按 {agent} 的要求",
"Routed": "路由到",
+35 -8
View File
@@ -760,6 +760,12 @@
const sub = el("span", "", w.fallback ? w.name : w.kind === "provider" ? "" : w.plan || "");
b.append(name, " ", sub, el("code", "mdl", w.fixed ? `${w.model}:${w.fixed}` : w.model));
if (w.fixed) b.title = t("{level} reasoning, fixed on this model in the group", { level: w.fixed });
if (w.fast) {
// the group sends this member in its vendor's fast mode
const f = el("small", "fb", t("fast"));
f.title = t("Sent in its vendor's fast mode, as the group says");
b.append(f);
}
if (w.fallback) b.append(el("small", "fb", t("fallback")));
const st = el("em"), bar = el("div", "bar"), bi = el("i"), tg = el("span", "tag");
bar.append(bi);
@@ -1218,7 +1224,7 @@
// all the row says, and its titles
const title = reqTitle(r, how, tr);
const sig = JSON.stringify([lang, said, how, title, r.time, r.agent, agentName(r.agent), ag?.icon, r.model, r.provider, r.kind, r.effort,
tr?.effort, tr?.picked, tr?.fixed, tr?.swapped && tr.done && tr.status < 400 ? [tr.model, tr.served] : 0, tr?.routed && tr.done && tr.status < 400 ? tr.served : 0, meta]);
tr?.effort, tr?.picked, tr?.fixed, tr?.fast, tr?.swapped && tr.done && tr.status < 400 ? [tr.model, tr.served] : 0, tr?.routed && tr.done && tr.status < 400 ? tr.served : 0, meta]);
ids.add(r.id);
let x = reqRows.get(r.id);
if (!x || x.sig !== sig) {
@@ -1266,6 +1272,12 @@
ef.title = effortNote(r, tr);
to.append(ef);
}
if (tr?.fast) {
// sent in its vendor's fast mode, as the group's member is
const ft = el("span", "ef", t("fast"));
ft.title = t("Sent in its vendor's fast mode, as the group says");
to.append(ft);
}
if (tr?.swapped && tr.done && tr.status < 400) to.append(swapTag(tr, true)); // beside the model asked for
else if (tr?.routed && tr.done && tr.status < 400) to.append(routedTag(tr));
b.append(when, asked, to, el("span", "meta", meta.join(" · ")));
@@ -1963,10 +1975,12 @@
const memberIcon = (id) => { const s = subOf(id); return s ? stackIcon(groupIcons(s)) : icon(modelOf(id)?.icon || "generic"); };
const memberName = (id) => { const s = subOf(id), m = modelOf(id); return s ? s.name : m ? m.name || m.id : id; };
const memberNote = (id) => subOf(id) ? t("routing group") : [modelOf(id)?.providerName, fixedOf(id) && fixedWords(fixedOf(id))].filter(Boolean).join(" · ");
// a member the group sends in its vendor's fast mode (Group.Fast)
const fastIn = (g, id) => !!g?.fast?.includes(id) && !subOf(id);
function memberLabel(g, id) {
const i = g.memberInfo?.find((x) => x.id === id), m = modelOf(id), s = subOf(id), f = fixedOf(id);
if (s) return `${t("routing group")} · ${s.name}`;
const at = f ? ` · ${fixedWords(f)}` : "";
const at = (f ? ` · ${fixedWords(f)}` : "") + (fastIn(g, id) ? ` · ${t("fast")}` : "");
if (m) return `${m.providerName} · ${m.name || m.id}${at}`;
return i?.name ? `${i.name} · ${i.model}${at}` : id;
}
@@ -1975,7 +1989,7 @@
}
function drawGroups() {
const newBtn = el("button", "text", t("New group"));
newBtn.onclick = () => { gEdit = { id: "", draft: { name: "", members: [], routing: "", affinity: "", rules: [] } }; renderGroups(); };
newBtn.onclick = () => { gEdit = { id: "", draft: { name: "", members: [], fast: [], routing: "", affinity: "", rules: [] } }; renderGroups(); };
gHead.replaceChildren(el("span", "label", t("Routing groups")), el("span", "grow"), el("span", "note", t("models agents pick as one")), newBtn);
const rows = [];
if (gEdit && !gEdit.id) rows.push(groupEditor(null));
@@ -2020,7 +2034,7 @@
if (!g.ready) tags.append(el("span", "tag bad", t("no member ready")));
const edit = el("button", "text", t("Edit"));
edit.onclick = (e) => { e.stopPropagation(); open(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], fast: [...(g.fast || [])], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); };
row.onclick = open;
row.append(ics, main, tags, edit);
return row;
@@ -2042,7 +2056,7 @@
b.setAttribute("aria-checked", String(on));
b.dataset.member = id;
b.append(el("span", "dot"), memberIcon(id), el("span", "n", memberName(id)));
const note = memberNote(id);
const note = [memberNote(id), fastIn(g, id) && t("fast")].filter(Boolean).join(" · ");
if (note) b.append(el("small", "", note));
b.title = on ? t("Every request goes to {name}", { name: memberLabel(g, id) })
: info && !info.ready ? t("No provider serves {id} now; it is skipped", { id })
@@ -2050,7 +2064,7 @@
b.onclick = (e) => {
e.stopPropagation(); // the card opens the editor; this picks
if (on) return;
groupAction("save", { id: g.id, name: g.name, members: g.members, routing: "manual", pick: id, affinity: g.affinity || "", rules: g.rules || [], effort: g.effort || "", classifier: g.classifier || "", context: g.context || 0, levels: g.levels || [], family: g.family || "" },
groupAction("save", { id: g.id, name: g.name, members: g.members, routing: "manual", pick: id, affinity: g.affinity || "", rules: g.rules || [], effort: g.effort || "", classifier: g.classifier || "", context: g.context || 0, levels: g.levels || [], family: g.family || "", fast: g.fast || [] },
t("{name}: every request to {model}", { name: g.name, model: memberName(id) }));
};
box.append(b);
@@ -2130,15 +2144,28 @@
if (d.members.includes(to)) { status(t("{name} at that reasoning is in the group already", { name: memberName(id) }), "err"); return; }
d.members[i] = to;
for (const r of d.rules) if (r.use === id) r.use = to;
d.fast = d.fast.map((x) => x === id ? to : x);
if (d.pick === id) d.pick = to;
draw(); drawRules();
}, fixed);
row.append(fx);
// its vendor's fast mode, where the model has one: priority
// processing, Claude's fast mode (provider.CanFast)
if (m?.canFast) {
const on = d.fast.includes(id);
const fb = el("button", "rt-cond rt-fixed rt-fast" + (on ? " on" : ""), t(on ? "Fast" : "Standard speed"));
fb.type = "button";
fb.setAttribute("aria-pressed", String(on));
fb.title = on ? t("Sent in its vendor's fast mode whatever the agent asks: quicker, at a higher price") : t("Sent at its vendor's usual speed; click to send it in fast mode");
fb.onclick = () => { d.fast = on ? d.fast.filter((x) => x !== id) : [...d.fast, id]; draw(); };
row.append(fb);
}
}
if (s) row.title = s.members.map((x) => memberLabel(s, x)).join(s.routing === "order" ? " → " : " · ");
if (!m && !s) { row.classList.add("off"); row.title = t("No provider serves {id} now; it is skipped", { id }); }
if (i) { const up = el("button", "text", t("Up")); up.onclick = () => { d.members.splice(i - 1, 0, d.members.splice(i, 1)[0]); draw(); }; row.append(up); }
const rm = el("button", "text", t("Remove"));
rm.onclick = () => { d.members.splice(i, 1); d.rules = d.rules.filter((r) => d.members.includes(r.use)); draw(); drawRules(); };
rm.onclick = () => { d.members.splice(i, 1); d.rules = d.rules.filter((r) => d.members.includes(r.use)); d.fast = d.fast.filter((x) => d.members.includes(x)); draw(); drawRules(); };
row.append(rm);
list.append(row);
});
@@ -2435,7 +2462,7 @@
if (d.effort === "auto" && !d.classifier) return status(t("Choose the model that rates how hard a turn is"), "warn");
if (own && !d.levels.length) return status(t("Pick a level to offer, or leave them to its models"), "warn");
saveBtn.classList.add("busy");
groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, pick: d.pick || "", affinity: d.affinity, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0, levels: own ? d.levels : [], family: g?.family || "" }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }));
groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, pick: d.pick || "", affinity: d.affinity, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0, levels: own ? d.levels : [], family: g?.family || "", fast: d.fast.filter((x) => d.members.includes(x)) }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }));
};
saveBtn.onclick = save;
bar.append(cancel, saveBtn);
+51
View File
@@ -0,0 +1,51 @@
package gui
import (
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// The Routing view saves the members sent fast and is told which models
// have a fast mode, and which members are sent in it.
func TestGroupSaveFast(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
for _, p := range []provider.Provider{
{ID: "oa", Name: "OpenAI", Key: "k", Responses: "https://api.openai.com/v1", Models: []string{"gpt-6.1-sol"}},
{ID: "rl", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"glm-5"}},
} {
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
}
mux := http.NewServeMux()
groupRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/groups/save", strings.NewReader(
`{"id":"sol","name":"Sol","members":["oa/gpt-6.1-sol:high","rl/glm-5"],"fast":["oa/gpt-6.1-sol:high"]}`)))
if w.Code != 200 {
t.Fatalf("%d %s", w.Code, w.Body)
}
if g, _, _ := provider.FindGroup("group/sol"); !slices.Equal(g.Fast, []string{"oa/gpt-6.1-sol:high"}) {
t.Fatalf("fast %v", g.Fast)
}
st := groupsState()
for _, m := range st.Models {
if want := m.ID == "oa/gpt-6.1-sol"; m.CanFast != want {
t.Errorf("%s canFast %v", m.ID, m.CanFast)
}
}
for _, g := range st.Groups {
for _, m := range g.Info {
if want := m.ID == "oa/gpt-6.1-sol:high"; m.Fast != want || m.CanFast != want {
t.Errorf("%s fast %v canFast %v", m.ID, m.Fast, m.CanFast)
}
}
}
}
+10 -1
View File
@@ -50,6 +50,10 @@ type memberJSON struct {
// Effort that effort ("provider/model:low"); "" for one without
Of string `json:"of,omitempty"`
Effort string `json:"effort,omitempty"`
// Fast: the group sends it in its vendor's fast mode; CanFast: its
// model has one (provider.CanFast)
Fast bool `json:"fast,omitempty"`
CanFast bool `json:"canFast,omitempty"`
// what a rule may send it: the tokens it takes, when known, and images
Context int `json:"context,omitempty"`
Images bool `json:"images,omitempty"`
@@ -65,6 +69,9 @@ type modelRef struct {
// Efforts: the model's reasoning levels, when known — those a group's
// member of it may be fixed at
Efforts []string `json:"efforts,omitempty"`
// CanFast: a group's member of it may be sent in its vendor's fast
// mode (provider.CanFast)
CanFast bool `json:"canFast,omitempty"`
}
type poolJSON struct {
@@ -132,7 +139,7 @@ func groupsState() groupsJSON {
served := provider.Served()
for _, e := range served {
if e.Group == "" {
out.Models = append(out.Models, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon, Context: e.Context, Efforts: e.Efforts})
out.Models = append(out.Models, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon, Context: e.Context, Efforts: e.Efforts, CanFast: provider.CanFast(e.Provider, e.Model)})
}
}
for _, g := range provider.Groups() {
@@ -177,6 +184,8 @@ func groupsState() groupsJSON {
if p, model, ok := provider.Resolve(of); ok {
_, who := onOf(p)
m.Ready, m.Provider, m.Name, m.Icon, m.Model, m.On = true, p.ID, p.Name, p.Icon, model, max(len(who), 1)
m.CanFast = provider.CanFast(p, model)
m.Fast = m.CanFast && g.IsFast(id)
for _, e := range served {
if e.Group == "" && e.Provider.ID == p.ID && e.Model == model {
m.Context, m.Images = e.Context, e.Images && (e.ImageInput == nil || *e.ImageInput)
+10 -1
View File
@@ -144,6 +144,14 @@ toggle per level, starting from the shared ones, and toggling them moves
nothing; saved, they go lowest first and the group's family stays. A group with
its own opens on them, none picked is refused, and back to shared saves none.
`group-member-fast.test.cjs` sends a group's member in its vendor's fast mode,
in Chromium and WebKit, English and Chinese (vs on Discord: "gpt-6.1 sol ·
high · fast"). The group's card reads "· fast" after the member's reasoning; in
the editor a model with a fast mode shows a "Standard speed"/"Fast" toggle
beside its reasoning and one without none, toggling moves nothing on the page,
a member whose reasoning changes stays fast under its new id, one taken out
leaves it, and the save sends the members sent fast as fast.
`routing-manual.test.cjs` routes a group by hand in Chromium and WebKit,
English and Chinese (#317: pick the model, as CC Switch picks a provider). A
manual group's card lists its models, the one every request goes to marked, and
@@ -681,6 +689,7 @@ The provider's editor names its models in no group under the tick (not once
unticked), each a click from a group; `?view=routing&newgroup=` opens the
window on one, and the tray panel asks `window/main` for it. The page doesn't
move, no left-border accent; English and Chinese, Chromium and WebKit.
`request-archive.test.cjs` turns the Gateway page's request archive on and
off: off unless turned on, refused with sync not set up with an s3:// bucket
(the reason in the footer, the switch left off), on with one, the bucket said.
@@ -694,7 +703,7 @@ WebKit, with `/api/plugins` faked.
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/rollup-short.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 internal/gui/tests/library-loading.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-blocked.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-usage-asked.test.cjs internal/gui/tests/claude-risk.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 internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.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/rollup-short.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 internal/gui/tests/library-loading.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-blocked.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-usage-asked.test.cjs internal/gui/tests/claude-risk.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 internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
@@ -0,0 +1,139 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A group's member may be sent in its vendor's fast mode (vs on Discord:
// "gpt-6.1 sol · high · fast"). The group's card reads "· fast" after the
// member's reasoning; in the group editor a model that has a fast mode
// (canFast) shows a toggle beside its reasoning, "Standard speed" or
// "Fast", and one that has none shows no toggle. Clicking it moves nothing
// on the page; saved, the members sent fast go as fast, and a member whose
// reasoning is changed stays fast under its new id, while one taken out
// leaves it. In English and Chinese, Chromium and WebKit.
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 models = [
{ id: "oa/gpt-6.1-sol", name: "gpt-6.1-sol", providerName: "OpenAI", icon: "generic", efforts: ["low", "medium", "high", "xhigh"], canFast: true },
{ id: "an/claude-opus-5-5", name: "claude-opus-5-5", providerName: "Anthropic", icon: "generic", canFast: true },
{ id: "rl/glm-5", name: "glm-5", providerName: "Relay", icon: "generic" },
];
const members = ["oa/gpt-6.1-sol:high", "an/claude-opus-5-5", "rl/glm-5"];
const groups = () => ({
models, pools: [],
groups: [{ id: "sol", name: "Sol", members, fast: ["oa/gpt-6.1-sol:high"], routing: "order", ready: true,
memberInfo: members.map((id) => ({ id, ready: true, fast: id === "oa/gpt-6.1-sol:high", canFast: !id.startsWith("rl/") })) }],
});
const words = {
en: { edit: "Edit", save: "Save", fast: "Fast", std: "Standard speed", label: "gpt-6.1-sol · high reasoning · fast", remove: "Remove" },
zh: { edit: "编辑", save: "保存", fast: "快速", std: "标准速度", label: "gpt-6.1-sol · high 推理强度 · 快速", remove: "移除" },
};
function serve(lang, posts) {
const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
return async (r) => {
const url = new URL(r.request().url());
const json = (data) => r.fulfill({ json: data });
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname === "/api/groups") return json(groups());
if (url.pathname.startsWith("/api/groups/")) {
posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") });
return json(groups());
}
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await r.fulfill({ body: await fs.readFile(file), contentType });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: a group's member is sent fast`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 }, reducedMotion: "reduce" })).newPage();
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-group-member-fast.png`) });
}
await browser.close();
});
page.setDefaultTimeout(5000);
const errors = [], posts = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=routing");
const card = page.locator(".rt-group", { hasText: "Sol" });
await card.waitFor();
assert((await card.locator(".mem").textContent()).includes(w.label), "the card says the member is sent fast");
await card.locator("button", { hasText: w.edit }).click();
const ed = page.locator(".rt-gedit");
const row = (name) => ed.locator(".fbrow", { has: page.locator(".n", { hasText: name }) });
const fast = (name) => row(name).locator("button.rt-fast");
await fast("gpt-6.1-sol").waitFor();
assert.equal(await fast("gpt-6.1-sol").textContent(), w.fast);
assert.equal(await fast("gpt-6.1-sol").getAttribute("aria-pressed"), "true");
assert.equal(await fast("claude-opus-5-5").textContent(), w.std);
assert.equal(await fast("claude-opus-5-5").getAttribute("aria-pressed"), "false");
assert.equal(await fast("glm-5").count(), 0, "no fast mode, no toggle");
const border = await page.evaluate(() => [...document.querySelectorAll(".rt-fast")].map((e) => getComputedStyle(e))
.filter((c) => c.borderLeftWidth !== c.borderRightWidth || c.borderLeftColor !== c.borderRightColor));
assert.equal(border.length, 0, "no left-border accent");
// toggles, the page kept still
await row("claude-opus-5-5").evaluate((x) => x.scrollIntoView({ block: "center" })); // by the test, as the reader would
await page.waitForTimeout(200);
const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + Math.round(document.querySelector(".rt-gedit").getBoundingClientRect().top));
const before = await at();
await fast("claude-opus-5-5").click();
assert.equal(await fast("claude-opus-5-5").textContent(), w.fast);
await fast("gpt-6.1-sol").click();
assert.equal(await fast("gpt-6.1-sol").textContent(), w.std);
await fast("gpt-6.1-sol").click();
assert.equal(await at(), before, "the clicks moved nothing");
// the gpt member's reasoning changed: still fast, under its new id
await row("gpt-6.1-sol").locator("button.rt-fixed:not(.rt-fast)").click();
await page.locator(".pop .opt, .pop [role=option], .pop li", { hasText: /^xhigh/ }).first().click();
assert.equal(await fast("gpt-6.1-sol").textContent(), w.fast, "fast follows the reasoning");
// the Claude member taken out takes its fast with it
await row("claude-opus-5-5").locator("button.text", { hasText: w.remove }).click();
assert.equal(await row("claude-opus-5-5").count(), 0);
const b = ed.locator("button.primary", { hasText: w.save });
await page.mouse.move(550, 600);
for (let i = 0; i < 10 && (await b.boundingBox()).y > 1400 - 160; i++) {
await page.mouse.wheel(0, 300);
await page.waitForTimeout(150);
}
const bb = await b.boundingBox();
await page.mouse.click(bb.x + bb.width / 2, bb.y + bb.height / 2);
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
const sent = posts.find((p) => p.path === "/api/groups/save");
assert.deepEqual(sent.body.members, ["oa/gpt-6.1-sol:xhigh", "rl/glm-5"]);
assert.deepEqual(sent.body.fast, ["oa/gpt-6.1-sol:xhigh"]);
const missing = await page.evaluate(() => [
"fast", "Fast", "Standard speed", "Sent in its vendor's fast mode, as the group says",
"Sent in its vendor's fast mode whatever the agent asks: quicker, at a higher price",
"Sent at its vendor's usual speed; click to send it in fast mode",
].filter((k) => !I18N.zh[k]));
assert.deepEqual(missing, [], "every string has its Chinese");
assert.deepEqual(errors, []);
});
}
}
+13 -2
View File
@@ -123,6 +123,11 @@ type Group struct {
// sent the one it has nearest, as ever. Empty offers the members'
// shared levels.
Levels []string `json:"levels,omitempty"`
// Fast are the members (as Members spells them) sent in their
// vendor's fast mode, where the model has one (see CanFast). Kept
// beside Members, not in their ids, so a version before it still
// routes to them, only not fast.
Fast []string `json:"fast,omitempty"`
// Family is a tag the group goes by in which agents are shown it
// (settings' Visible), with its id.
Family string `json:"family,omitempty"`
@@ -149,6 +154,9 @@ type Member struct {
// asked of the model whatever the agent or the group's classifier
// asked; "" follows the group.
Effort string
// Fast is set on a member the group sends in its vendor's fast mode
// (Group.Fast), where its model has one.
Fast bool
}
// Groups are the ids of the groups in the group the model is of, the
@@ -164,7 +172,7 @@ func (m Member) Groups() []string {
// Below is the member as the group at depth (0 the group itself, 1 the
// group in it Path[0] names, …) has it.
func (m Member) Below(depth int) Member {
return Member{ID: m.Path[depth], Path: m.Path[depth:], Via: m.Via[depth:], Provider: m.Provider, Model: m.Model, Effort: m.Effort}
return Member{ID: m.Path[depth], Path: m.Path[depth:], Via: m.Via[depth:], Provider: m.Provider, Model: m.Model, Effort: m.Effort, Fast: m.Fast}
}
// maxNest is how deep groups in groups may go.
@@ -350,7 +358,7 @@ func membersIn(entries []Entry, all []Group, g Group) []Member {
continue
}
seen[key] = true
out = append(out, Member{ID: at[0], Path: at, Via: via, Provider: p, Model: m, Effort: effort})
out = append(out, Member{ID: at[0], Path: at, Via: via, Provider: p, Model: m, Effort: effort, Fast: g.IsFast(id)})
}
}
walk(g, nil, nil, []string{g.ID})
@@ -469,6 +477,9 @@ func SaveGroup(g Group) error {
return errors.New("a group needs a model in it")
}
entries := providerEntries()
if err := cleanFast(entries, &g); err != nil {
return err
}
for i, m := range g.Members {
if model, effort := memberEffortIn(entries, m); effort != "" && strings.HasPrefix(model, GroupPrefix) {
return fmt.Errorf("%s is a group: its models reason as it says, so it takes no effort of its own (:%s)", model, effort)
+159
View File
@@ -0,0 +1,159 @@
package provider
// A group's member may be sent in its vendor's fast mode (Discord: "gpt-6.1
// sol · high · fast"): OpenAI's priority processing (service_tier
// "priority", Codex's Fast) on a ChatGPT account or an OpenAI key, Cursor's
// -fast model, Claude's fast mode (speed "fast") on an Anthropic key for
// the Opus models that have it. A member whose model has none is sent as
// it is.
//
// Group.Fast lists those members as Members spells them, kept apart from
// the member's id so that a version before it, which reads no "fast",
// still routes to the member, only not fast. Typed, it is a last part
// ":fast" after the effort, if any: "codex/gpt-6.1-sol:high:fast".
import (
"fmt"
"regexp"
"slices"
"strings"
)
// FastWord is the last part of a member typed to be sent fast.
const FastWord = "fast"
// MemberFast splits a member typed with ":fast" after it into the member
// and true: "a/m:high:fast" is a/m:high, fast, unless a has a model
// "m:high:fast" of its own.
func MemberFast(id string) (member string, fast bool) {
return memberFastIn(nil, id)
}
func memberFastIn(entries []Entry, id string) (string, bool) {
i := strings.LastIndex(id, ":")
if i <= 0 || !strings.EqualFold(id[i+1:], FastWord) {
return id, false
}
if !strings.HasPrefix(id, GroupPrefix) && isModel(entries, id) {
return id, false
}
return id[:i], true
}
// IsFast reports whether the group sends its member id fast.
func (g Group) IsFast(id string) bool {
return slices.Contains(g.Fast, id)
}
// CanFast reports whether model, as p serves it, has a fast mode magpie
// can ask for: a ChatGPT account's GPT models (Codex's Fast), Cursor's
// models with a -fast one, an OpenAI key's GPT and o-series models on its
// Responses API (priority processing), and an Anthropic key's Opus models with fast mode.
// A Claude subscription runs Claude Code itself, and a relay or a cloud
// (Bedrock, Vertex) may refuse the field, so neither is.
func CanFast(p Provider, model string) bool {
if p.Account != nil {
switch p.Account.Agent {
case "codex":
return strings.HasPrefix(model, "gpt-")
case "cursor":
if strings.HasSuffix(model, "-fast") {
return false
}
_, ok := CursorVariants(model + "-fast")
return ok
}
return false
}
if p.IsBedrock() {
return false
}
// on OpenAI's Responses API: a request translated for its Chat API is
// told no tier (TestCursorPluginFast)
if HostOf(p.Responses) == "api.openai.com" && openAIFast.MatchString(model) {
return true
}
return HostOf(p.Anthropic) == "api.anthropic.com" && ClaudeFast(model)
}
var (
openAIFast = regexp.MustCompile(`^(gpt-|o\d)`)
// a dated id is its model's: claude-opus-4-8-20260501
claudeDated = regexp.MustCompile(`-\d{8}$`)
)
// claudeFastModels are the Claude models with fast mode (speed "fast",
// beta fast-mode-2026-02-01).
var claudeFastModels = []string{"claude-opus-4-8", "claude-opus-5", "claude-opus-5-5"}
// ClaudeFast reports whether a Claude model has fast mode.
func ClaudeFast(model string) bool {
return slices.Contains(claudeFastModels, claudeDated.ReplaceAllString(strings.ToLower(model), ""))
}
// cleanFast takes a member typed with ":fast" as one the group sends fast,
// and keeps g.Fast to the group's own models as Members spells them, each
// once. A group in the group is sent as its own members say, so it takes
// none; a model with no fast mode is refused, as long as it is served.
func cleanFast(entries []Entry, g *Group) error {
for i, m := range g.Members {
if id, fast := memberFastIn(entries, strings.TrimSpace(m)); fast {
g.Members[i] = id
g.Fast = append(g.Fast, id)
}
}
key := func(id string) string { return cleanMember(entries, strings.TrimSpace(id)) }
var members []string
for _, m := range g.Members {
members = append(members, key(m))
}
var out []string
for _, f := range g.Fast {
f = key(f)
if !slices.Contains(members, f) || slices.Contains(out, f) {
continue // taken out of the group
}
model, _ := memberEffortIn(entries, f)
if strings.HasPrefix(model, GroupPrefix) {
return fmt.Errorf("%s is a group: its models are sent as it says, so it takes no :%s", model, FastWord)
}
if p, m, ok := resolveIn(entries, model); ok && !CanFast(p, m) {
return fmt.Errorf("%s has no fast mode magpie can ask for (ChatGPT and OpenAI GPT models, Cursor's with a fast one, and Claude Opus 4.8 and 5 on an Anthropic key have)", model)
}
out = append(out, f)
}
g.Fast = out
return nil
}
// SetMemberFast sends the group's member id fast, or not, where it is in
// the group.
func (g *Group) SetMemberFast(id string, fast bool) {
g.Fast = slices.DeleteFunc(slices.Clone(g.Fast), func(x string) bool { return x == id })
if fast && slices.Contains(g.Members, id) {
g.Fast = append(g.Fast, id)
}
}
// RenameMember has the group's member from go by to (its effort changed):
// its rules, its pick and its fast mode follow it.
func (g *Group) RenameMember(from, to string) {
for i, m := range g.Members {
if m == from {
g.Members[i] = to
}
}
for i, r := range g.Rules {
if r.Use == from {
g.Rules[i].Use = to
}
}
if g.Pick == from {
g.Pick = to
}
for i, f := range g.Fast {
if f == from {
g.Fast[i] = to
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package provider
import (
"slices"
"strings"
"testing"
)
// fastHome is a machine of its own with an OpenAI key, an Anthropic key, a
// relay and a ChatGPT account.
func fastHome(t *testing.T) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
for _, p := range []Provider{
{ID: "oa", Name: "OpenAI", Key: "k", Responses: "https://api.openai.com/v1", Models: []string{"gpt-6.1-sol", "text-embedding-3"}},
{ID: "an", Name: "Anthropic", Key: "k", Anthropic: "https://api.anthropic.com", Models: []string{"claude-opus-5-5", "claude-opus-4-8-20260501", "claude-sonnet-5"}},
{ID: "rl", Name: "Relay", Key: "k", Chat: "https://relay.example/v1", Anthropic: "https://relay.example", Models: []string{"gpt-6.1-sol", "claude-opus-5-5", "odd:fast"}},
} {
if err := Save(p); err != nil {
t.Fatal(err)
}
}
}
func TestCanFast(t *testing.T) {
fastHome(t)
codex := Provider{ID: "codex", Account: &Account{Agent: "codex"}}
claude := Provider{ID: "claude", Account: &Account{Agent: "claude"}, Anthropic: "https://api.anthropic.com"}
oa, _ := Find("oa")
an, _ := Find("an")
rl, _ := Find("rl")
for _, c := range []struct {
p Provider
model string
want bool
}{
{codex, "gpt-6.1-sol", true},
{claude, "claude-opus-5-5", false}, // Claude Code's own
{*oa, "gpt-6.1-sol", true},
{*oa, "o4-mini", true},
{*oa, "text-embedding-3", false},
{Provider{ID: "oc", Chat: "https://api.openai.com/v1"}, "gpt-6.1-sol", false}, // its Chat API is told no tier
{*an, "claude-opus-5-5", true},
{*an, "claude-opus-4-8-20260501", true},
{*an, "claude-sonnet-5", false},
{*rl, "gpt-6.1-sol", false},
{*rl, "claude-opus-5-5", false},
} {
if got := CanFast(c.p, c.model); got != c.want {
t.Errorf("%s/%s: %v, want %v", c.p.ID, c.model, got, c.want)
}
}
}
// A member typed ":fast" is saved as the member, listed in Fast; Fast
// keeps to the group's own members, each once, and follows a renamed
// provider. A model with no fast mode, or a group, is refused it.
func TestSaveGroupFast(t *testing.T) {
fastHome(t)
if id, fast := MemberFast("oa/gpt-6.1-sol:high:fast"); id != "oa/gpt-6.1-sol:high" || !fast {
t.Fatalf("split %q %v", id, fast)
}
if err := SaveGroup(Group{ID: "f", Members: []string{"oa/gpt-6.1-sol:high:fast", "an/claude-opus-5-5", "rl/odd:fast"},
Fast: []string{"an/claude-opus-5-5", "an/claude-opus-5-5", "an/gone"}}); err != nil {
t.Fatal(err)
}
g, ms, _ := FindGroup("group/f")
if !slices.Equal(g.Members, []string{"oa/gpt-6.1-sol:high", "an/claude-opus-5-5", "rl/odd:fast"}) {
t.Fatalf("members %v", g.Members)
}
if f := slices.Sorted(slices.Values(g.Fast)); !slices.Equal(f, []string{"an/claude-opus-5-5", "oa/gpt-6.1-sol:high"}) {
t.Fatalf("fast %v", g.Fast)
}
if len(ms) != 3 || !ms[0].Fast || ms[0].Effort != "high" || !ms[1].Fast || ms[2].Fast {
t.Fatalf("resolved %+v", ms)
}
g.RenameMember("oa/gpt-6.1-sol:high", "oa/gpt-6.1-sol:low")
if !g.IsFast("oa/gpt-6.1-sol:low") || g.IsFast("oa/gpt-6.1-sol:high") {
t.Fatalf("renamed member's fast %v", g.Fast)
}
g.SetMemberFast("an/claude-opus-5-5", false)
g.SetMemberFast("an/not-in-it", true)
if !slices.Equal(g.Fast, []string{"oa/gpt-6.1-sol:low"}) {
t.Fatalf("set %v", g.Fast)
}
if err := Rename("oa", "openai"); err != nil {
t.Fatal(err)
}
if g, _, _ = FindGroup("group/f"); !slices.Contains(g.Fast, "openai/gpt-6.1-sol:high") {
t.Fatalf("after the rename %v", g.Fast)
}
for _, m := range []string{"rl/gpt-6.1-sol:fast", "an/claude-sonnet-5:fast", "group/f:fast"} {
err := SaveGroup(Group{ID: "g", Members: []string{m, "an/claude-opus-5-5"}})
if err == nil || !(strings.Contains(err.Error(), "no fast mode") || strings.Contains(err.Error(), "takes no :fast")) {
t.Errorf("%s: %v", m, err)
}
}
}
+3
View File
@@ -77,6 +77,9 @@ func Rename(from, to string) error {
for k, r := range g.Rules {
g.Rules[k].Use = renamedRef(r.Use, from, to)
}
for k, m := range g.Fast {
g.Fast[k] = renamedRef(m, from, to)
}
g.Classifier = renamedRef(g.Classifier, from, to)
g.Pick = renamedRef(g.Pick, from, to)
}
+46
View File
@@ -0,0 +1,46 @@
package tui
import (
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// E steps the picked member's own effort, F sends it in its vendor's fast
// mode or not; a model with none is refused it, and the group shows both.
func TestGroupMemberEffortAndFast(t *testing.T) {
home(t)
if err := provider.Save(provider.Provider{ID: "an", Name: "Anthropic", Key: "k", Anthropic: "https://api.anthropic.com", Models: []string{"claude-opus-5-5"}}); err != nil {
t.Fatal(err)
}
if err := provider.SaveGroup(provider.Group{ID: "opus", Name: "Opus", Members: []string{"an/claude-opus-5-5", "a/m"}}); err != nil {
t.Fatal(err)
}
m := press(t, model{w: 120, h: 40}, "3", "enter")
if m.mode != modeGroup || m.gsel != 0 {
t.Fatalf("mode %d, picked %d", m.mode, m.gsel)
}
m = press(t, m, "F")
wantFlash(t, m, true, "an/claude-opus-5-5 fast")
if g := group(t, "opus"); !slices.Equal(g.Fast, []string{"an/claude-opus-5-5"}) {
t.Fatalf("fast %v", g.Fast)
}
if v := m.View(); !strings.Contains(v, "· fast") {
t.Fatalf("fast not shown:\n%s", v)
}
m = press(t, m, "E")
wantFlash(t, m, true, "an/claude-opus-5-5:low set")
m = press(t, m, "E")
if g := group(t, "opus"); !slices.Equal(g.Members, []string{"an/claude-opus-5-5:medium", "a/m"}) || !slices.Equal(g.Fast, []string{"an/claude-opus-5-5:medium"}) {
t.Fatalf("members %v fast %v", g.Members, g.Fast)
}
m = press(t, m, "F")
if g := group(t, "opus"); len(g.Fast) != 0 {
t.Fatalf("still fast %v", g.Fast)
}
// a/m is on a relay: no fast mode to ask for
m = press(t, m, "down", "F")
wantFlash(t, m, false, "no fast mode")
}
+33
View File
@@ -381,6 +381,29 @@ func (m model) updateGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.openClassifier(g)
case "E", "F":
// the model's own effort and fast mode, whatever the agent asks
if onRule || n == 0 || strings.HasPrefix(g.Members[m.gsel], provider.GroupPrefix) {
return m, nil
}
id := g.Members[m.gsel]
if key == "F" {
fast := !g.IsFast(id)
said := " not fast"
if fast {
said = " fast"
}
return m, saveGroup(g.ID, func(g *provider.Group) error {
g.SetMemberFast(id, fast)
return nil
}, id+said)
}
model, effort := provider.MemberEffort(id)
to := provider.WithMemberEffort(model, nextMemberEffort(effort))
return m, saveGroup(g.ID, func(g *provider.Group) error {
g.RenameMember(id, to)
return nil
}, to+" set")
case "o":
return m, saveGroup(g.ID, nextRouting, g.Name+" routing changed")
case "s":
@@ -483,6 +506,13 @@ func (m model) updateGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// nextMemberEffort is the effort E sets a member at after effort: the
// agent's (""), then low to max, then the agent's again.
func nextMemberEffort(effort string) string {
cycle := []string{"", "low", "medium", "high", "xhigh", "max"}
return cycle[(slices.Index(cycle, effort)+1)%len(cycle)]
}
// renamedMsg is a group given another id: the page follows it.
type renamedMsg struct{ from, to string }
@@ -568,6 +598,9 @@ func (m model) viewGroup() string {
if i == m.gsel {
marker, name = sCursor.Render("▸ "), sNameOn.Render(id)
}
if g.IsFast(id) {
name += sMuted.Render(" · fast")
}
b.WriteString(pad + marker + sFaint.Render(fmt.Sprintf("%d ", i+1)) + name + "\n")
}
b.WriteString("\n" + pad + " " + sFaint.Render("rules · as a turn begins, the first that matches sends it to its model first") + "\n")
+1 -1
View File
@@ -701,7 +701,7 @@ func (m model) View() string {
body = m.viewGroup()
// two lines: there is more to do to a group than one holds
footer = hints("↑↓", "model / rule", "J K", "move", "a", "add model", "n", "new rule", "↵", "edit rule", "d", "take out", "esc", "back") + "\n" +
pad + hints("c", "classifier", "o", "routing", "s", "stays", "x", "context", "l", "levels", "f", "family", "R", "rename")
pad + hints("E", "effort", "F", "fast", "c", "classifier", "o", "routing", "s", "stays", "x", "context", "l", "levels", "f", "family", "R", "rename")
case modeName:
body = m.viewName()
footer = hints("↵", "save", "esc", "cancel")