gateway: a model Claude Code names that magpie doesn't serve goes to the one it is set to use for that tier — claude-haiku-4-5-… for a title or a small task goes to its haiku tier's model, opus and sonnet to theirs, a name of no tier to its main model, rather than to some provider that happens to list the name and wasn't picked for it. Other agents, and ids magpie shows, are sent as asked
This commit is contained in:
5 files changed
+147
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user