From 4cbd96a3e47aef877284dbcb322bb9afa3ee1c1c Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 28 Sep 2026 13:25:25 +0800 Subject: [PATCH] =?UTF-8?q?gateway:=20a=20model=20Claude=20Code=20names=20?= =?UTF-8?q?that=20magpie=20doesn't=20serve=20goes=20to=20the=20one=20it=20?= =?UTF-8?q?is=20set=20to=20use=20for=20that=20tier=20=E2=80=94=20claude-ha?= =?UTF-8?q?iku-4-5-=E2=80=A6=20for=20a=20title=20or=20a=20small=20task=20g?= =?UTF-8?q?oes=20to=20its=20haiku=20tier's=20model,=20opus=20and=20sonnet?= =?UTF-8?q?=20to=20theirs,=20a=20name=20of=20no=20tier=20to=20its=20main?= =?UTF-8?q?=20model,=20rather=20than=20to=20some=20provider=20that=20happe?= =?UTF-8?q?ns=20to=20list=20the=20name=20and=20wasn't=20picked=20for=20it.?= =?UTF-8?q?=20Other=20agents,=20and=20ids=20magpie=20shows,=20are=20sent?= =?UTF-8?q?=20as=20asked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/claude.go | 39 ++++++++++++++++++++++++++++++++ internal/agent/standin_test.go | 37 ++++++++++++++++++++++++++++++ internal/gateway/gateway.go | 33 +++++++++++++++++++++++++++ internal/gateway/standin_test.go | 35 ++++++++++++++++++++++++++++ main.go | 3 +++ 5 files changed, 147 insertions(+) create mode 100644 internal/agent/standin_test.go create mode 100644 internal/gateway/standin_test.go diff --git a/internal/agent/claude.go b/internal/agent/claude.go index b88f9aed..8b2a7044 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -3,6 +3,7 @@ package agent import ( "cmp" "fmt" + "os" "path/filepath" "runtime" "strings" @@ -276,3 +277,41 @@ var claudeManaged = func() string { } return "/etc/claude-code/managed-settings.json" } + +// StandIn is the model Claude Code is set to use in place of one it named +// that magpie doesn't serve: claude-haiku-4-5-… for a title or a small +// task goes to its haiku tier's model, and a name of no tier to its main +// model. "" when Claude Code isn't routed through magpie or asked for +// isn't Claude Code. For gateway.StandIn. +func StandIn(agent, model string) string { + if agent != "claude" { + return "" + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return claudeStandIn(filepath.Join(home, ".claude", "settings.json"), model) +} + +func claudeStandIn(path, model string) string { + env := func(k string) string { v, _ := edit.GetJSON(path, "env."+k); return v } + if env("ANTHROPIC_BASE_URL") != gateway.URL() { + return "" + } + m := strings.ToLower(model) + for _, t := range claudeTiers { + if strings.Contains(m, t) { + if v := env(tierEnv(t)); v != "" { + return v + } + if t == "haiku" { + if v := env("ANTHROPIC_SMALL_FAST_MODEL"); v != "" { + return v + } + } + break + } + } + return env("ANTHROPIC_MODEL") +} diff --git a/internal/agent/standin_test.go b/internal/agent/standin_test.go new file mode 100644 index 00000000..d35b87a5 --- /dev/null +++ b/internal/agent/standin_test.go @@ -0,0 +1,37 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/gateway" +) + +func TestClaudeStandIn(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + write := func(s string) { + if err := os.WriteFile(path, []byte(s), 0o644); err != nil { + t.Fatal(err) + } + } + write(`{"env":{"ANTHROPIC_BASE_URL":"` + gateway.URL() + `","ANTHROPIC_MODEL":"a/main","ANTHROPIC_DEFAULT_HAIKU_MODEL":"a/small","ANTHROPIC_DEFAULT_OPUS_MODEL":"a/big"}}`) + for asked, want := range map[string]string{ + "claude-haiku-4-5-20251001": "a/small", + "claude-opus-5-5": "a/big", + "claude-sonnet-5": "a/main", // a tier without a model follows the main one + "gpt-5": "a/main", + } { + if got := claudeStandIn(path, asked); got != want { + t.Errorf("%s: %q, want %q", asked, got, want) + } + } + // Claude Code on Anthropic's own endpoint: nothing stands in + write(`{"env":{"ANTHROPIC_MODEL":"a/main"}}`) + if got := claudeStandIn(path, "claude-haiku-4-5"); got != "" { + t.Errorf("not routed: %q", got) + } + if got := StandIn("codex", "claude-haiku-4-5"); got != "" { + t.Errorf("another agent: %q", got) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 3e5ec1e6..453c77bf 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -50,6 +50,37 @@ func agentOf(r *http.Request) string { return usage.AgentOf(r.Header.Get("User-Agent")) } +// StandIn is the model an agent is set to use in place of one it named that +// magpie doesn't serve it: Claude Code asks for claude-haiku-… by name for +// its small tasks, whatever its haiku tier is set to, and that goes to the +// tier's model rather than to some provider that happens to list the name. +// "" leaves the model as asked. Set by main. +var StandIn func(agent, model string) string + +// standIn is StandIn's model for one magpie shows no entry for: not a +// catalog id or model, nor a ready provider's "provider/model". +func standIn(agent, asked string) string { + if StandIn == nil || asked == "" || strings.HasPrefix(asked, provider.GroupPrefix) { + return "" + } + id := strings.TrimSuffix(asked, "[1m]") + for _, e := range provider.Catalog() { + if e.ID == id || e.Model == id { + return "" + } + } + if pid, _, ok := strings.Cut(id, "/"); ok { + if p, err := provider.Find(pid); err == nil && p.Ready() { + return "" + } + } + m := StandIn(agent, asked) + if m == asked { + return "" + } + return m +} + // Version is set by main. var Version = "dev" @@ -589,6 +620,8 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro asked := call.Model if id, ok := provider.GroupFor(asked); ok { asked = id + } else if m := standIn(call.Agent, asked); m != "" { + asked = m } p, model, ok := provider.Resolve(asked) if !ok { diff --git a/internal/gateway/standin_test.go b/internal/gateway/standin_test.go new file mode 100644 index 00000000..393abefb --- /dev/null +++ b/internal/gateway/standin_test.go @@ -0,0 +1,35 @@ +package gateway + +import ( + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// a model the agent names that magpie doesn't serve goes to the one the +// agent is set to use for it; one magpie serves is sent as asked +func TestStandIn(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + f := &fake{t: t, ctype: "application/json", reply: `{"id":"c1","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`} + setup(t, provider.Chat, f) + var asked []string + StandIn = func(agent, model string) string { asked = append(asked, model); return "fake/m1" } + t.Cleanup(func() { StandIn = nil }) + + code, body := post(t, "/v1/chat/completions", `{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"hi"}]}`) + if code != 200 || modelOf(f.got) != "m1" || !strings.Contains(body, "OK") { + t.Fatalf("stood in: %d %s, upstream %s", code, body, f.got) + } + for _, m := range []string{"m1", "fake/m1", "fake/other"} { + asked = nil + if code, body := post(t, "/v1/chat/completions", `{"model":"`+m+`","messages":[{"role":"user","content":"hi"}]}`); code != 200 || len(asked) != 0 { + t.Fatalf("%s: %d %s, stand-in asked for %v", m, code, body, asked) + } + } + // no stand-in: unknown as before + StandIn = func(string, string) string { return "" } + if code, _ := post(t, "/v1/chat/completions", `{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"hi"}]}`); code != 404 { + t.Fatalf("without a stand-in: %d", code) + } +} diff --git a/main.go b/main.go index e5f24eab..a9af6df7 100644 --- a/main.go +++ b/main.go @@ -103,6 +103,9 @@ func run(args []string) error { // a provider added, edited or removed, or a list fetched anew, reaches // the model lists agents keep in files of their own catalog.Changed = agent.SyncCatalog + // a model Claude Code names that magpie doesn't serve goes to the one + // it is set to use for that tier + gateway.StandIn = agent.StandIn // the setup kept the same on every computer, by whichever serves gateway.WhileServing = append(gateway.WhileServing, davsync.Run) if len(args) == 0 {