While Pi's ~/.pi/agent/settings.json has a non-empty enabledModels (its Ctrl+P / /scoped-models list), a new session starts on defaultProvider/defaultModel only when that list takes the model in, else on the list's first entry, so a model picked in magpie outside the list was written and never used. Setting Pi's model now adds it to enabledModels as provider/model, as Pi's own picker does, when nothing in the list takes it in already: the same reference with or without a :level after it, or a glob (*, ?, [..], without case) that the reference or its model id fits. The user's entries, their order and comments stay; no list is made where there is none, and an empty one is left as it is. Turning the model back to Pi's own takes magpie/… entries out with magpie's provider, removing the key rather than leaving it empty. The model field shows what a new session starts on: the list's first model when the saved default isn't in the list and that entry names one model outright. The list itself isn't editable in magpie yet; Field is single-valued, and an editor needs a multi-value field kind in the API, GUI (a multi-select, reorderable picker), TUI and CLI. Pi's models.json now gives each magpie model the API its provider speaks natively, so the gateway passes the request through instead of translating Chat: Responses-native models keep openai-responses; a Messages-only provider's (a custom Anthropic-compatible endpoint, as Cleary's) get api anthropic-messages with baseUrl the gateway's root (Anthropic's SDK adds /v1/messages). A Claude from 4.6 on also gets compat.forceAdaptiveThinking, so Pi asks for adaptive thinking and an effort, not the budget such a Claude refuses (why these stayed on Chat before). Magpie's models stay under providers.magpie; other providers are untouched. Why Chat→Messages could 400 where Pi's direct Messages didn't: with reasoning on, buildAnthropic raised max_tokens to budget+4096 (28096 for high) past the client's own max_completion_tokens, which Pi sets to the model's output limit, so an upstream capped below that refused it. The budget now fits under a cap the client gives (at most the cap less 1024; no thinking when the cap is 2048 or less); with no cap it still raises max_tokens as before. Tested: go test -tags nogui ./... (new: TestPiTakes, TestPiModelJoinsEnabledModels, TestPiModelOffLeavesEnabledModels, TestThinkingBudgetFitsTheClientsCap; TestPiModelsAskedOnTheirNativeAPI extended), nogui builds for darwin, linux and windows. In a sandbox HOME: settings.json with enabledModels [xai/grok-4.6, openai-codex/gpt-5.6-sol, deepseek/deepseek-flash], magpie pi magpie/cleary/kimi-k3 appended the model and kept the rest, magpie pi default took it out again; models.json listed cleary's models on anthropic-messages, a Chat relay's on the provider's openai-completions, a Responses vendor's on openai-responses. Pi 0.87.1 loaded that models.json (--list-models) and, against a fake Messages endpoint standing in for the gateway (MAGPIE_ADDR on a spare port), posted to /v1/messages with the gateway key: kimi-k3 with a 15360-token budget under max_tokens 16384, claude-sonnet-5 with adaptive thinking and effort high.
142 lines
4.8 KiB
Go
142 lines
4.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/yetone/magpie/internal/edit"
|
|
)
|
|
|
|
func TestPiTakes(t *testing.T) {
|
|
for _, c := range []struct {
|
|
pattern, ref string
|
|
want bool
|
|
}{
|
|
{"xai/grok-4.6", "xai/grok-4.6", true},
|
|
{"XAI/Grok-4.6", "xai/grok-4.6", true},
|
|
{"xai/grok-4.6:high", "xai/grok-4.6", true},
|
|
{"xai/*", "xai/grok-4.6", true},
|
|
{"*grok*", "xai/grok-4.6", true},
|
|
{"magpie/*", "magpie/deepseek/deepseek-v4", false}, // Pi's * stops at a /
|
|
{"magpie/*/*", "magpie/deepseek/deepseek-v4", true},
|
|
{"deepseek/*:low", "magpie/deepseek/deepseek-v4", true}, // the model id fits
|
|
{"grok-4.6", "xai/grok-4.6", false}, // a bare id: added beside
|
|
{"xai/grok-4.6", "xai/grok-4.7", false},
|
|
{"openai-codex/gpt-5.6-sol", "opencode-go/deepseek-v4-flash", false},
|
|
} {
|
|
if got := piTakes(c.pattern, c.ref); got != c.want {
|
|
t.Errorf("piTakes(%q, %q) = %v", c.pattern, c.ref, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pi starts a new session on defaultModel only when enabledModels takes it
|
|
// in: the model magpie picks is added to the list, the user's entries
|
|
// kept; there is no list made where there was none, and none left empty
|
|
// (#160)
|
|
func TestPiModelJoinsEnabledModels(t *testing.T) {
|
|
home := syncHome(t)
|
|
settings := filepath.Join(home, ".pi", "agent", "settings.json")
|
|
os.MkdirAll(filepath.Dir(settings), 0o755)
|
|
os.WriteFile(settings, []byte(`{
|
|
// the user's own
|
|
"enabledModels": ["xai/grok-4.6:high", "openai-codex/gpt-5.6-sol", "deepseek/*"],
|
|
"defaultProvider": "xai",
|
|
"defaultModel": "grok-4.6"
|
|
}
|
|
`), 0o644)
|
|
f := pi(home).Field("model")
|
|
if got := f.Get(); got != "xai/grok-4.6" {
|
|
t.Fatalf("Get = %q", got)
|
|
}
|
|
if err := f.Set("opencode-go/deepseek-v4-flash-vision-exp"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := `["xai/grok-4.6:high","openai-codex/gpt-5.6-sol","deepseek/*","opencode-go/deepseek-v4-flash-vision-exp"]`
|
|
if got := strings.Join(strings.Fields(mustGet(t, settings, "enabledModels")), ""); got != want {
|
|
t.Errorf("enabledModels = %s, want %s", got, want)
|
|
}
|
|
if !strings.Contains(readFile(settings), "// the user's own") {
|
|
t.Errorf("comment lost: %s", readFile(settings))
|
|
}
|
|
if got := f.Get(); got != "opencode-go/deepseek-v4-flash-vision-exp" {
|
|
t.Errorf("Get = %q", got)
|
|
}
|
|
// a model the list takes in already isn't added again
|
|
before := mustGet(t, settings, "enabledModels")
|
|
for _, v := range []string{"openai-codex/gpt-5.6-sol", "deepseek/deepseek-v4-pro", "xai/grok-4.6"} {
|
|
if err := f.Set(v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if got := mustGet(t, settings, "enabledModels"); got != before {
|
|
t.Errorf("enabledModels grew: %s", got)
|
|
}
|
|
|
|
// set outside magpie: the model shown is the one Pi starts on
|
|
edit.SetJSON(settings, edit.KV{Path: "defaultModel", Value: "grok-3"})
|
|
if got := f.Get(); got != "xai/grok-4.6" {
|
|
t.Errorf("Get with a default outside the list = %q, want the list's first", got)
|
|
}
|
|
|
|
// no list: none is made
|
|
os.WriteFile(settings, []byte(`{"defaultProvider":"xai","defaultModel":"grok-4.6"}`), 0o644)
|
|
if err := f.Set("opencode-go/deepseek-v4-flash"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := edit.GetJSON(settings, "enabledModels"); ok {
|
|
t.Errorf("enabledModels made: %s", readFile(settings))
|
|
}
|
|
// an empty one means no list to Pi, and stays as it is
|
|
os.WriteFile(settings, []byte(`{"enabledModels":[]}`), 0o644)
|
|
if err := f.Set("opencode-go/deepseek-v4-flash"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := mustGet(t, settings, "enabledModels"); got != "[]" {
|
|
t.Errorf("empty enabledModels = %s", got)
|
|
}
|
|
}
|
|
|
|
// Turning Pi's model back to its own takes magpie's models out of the list
|
|
// with magpie's provider; a list left with nothing goes, not left empty
|
|
func TestPiModelOffLeavesEnabledModels(t *testing.T) {
|
|
home := syncHome(t)
|
|
settings := filepath.Join(home, ".pi", "agent", "settings.json")
|
|
os.MkdirAll(filepath.Dir(settings), 0o755)
|
|
f := pi(home).Field("model")
|
|
|
|
os.WriteFile(settings, []byte(`{"enabledModels":["xai/grok-4.6","magpie/*/*"]}`), 0o644)
|
|
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Set("magpie/deepseek/deepseek-v4"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Set(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// the user's glob is theirs
|
|
if got := strings.Join(strings.Fields(mustGet(t, settings, "enabledModels")), ""); got != `["xai/grok-4.6","magpie/*/*"]` {
|
|
t.Errorf("enabledModels = %s", got)
|
|
}
|
|
|
|
os.WriteFile(settings, []byte(`{"enabledModels":["magpie/relay/glm-4.6"],"defaultProvider":"magpie","defaultModel":"relay/glm-4.6"}`), 0o644)
|
|
if err := f.Set(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := edit.GetJSON(settings, "enabledModels"); ok {
|
|
t.Errorf("enabledModels left: %s", readFile(settings))
|
|
}
|
|
}
|
|
|
|
func mustGet(t *testing.T, path, key string) string {
|
|
t.Helper()
|
|
v, ok := edit.GetJSON(path, key)
|
|
if !ok {
|
|
t.Fatalf("%s: no %s in %s", path, key, readFile(path))
|
|
}
|
|
return v
|
|
}
|