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.
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/yetone/magpie/internal/edit"
|
|
)
|
|
|
|
// Pi's enabledModels (settings.json) is its Ctrl+P cycle and /scoped-models
|
|
// list. While it names anything, a new session starts on defaultModel only
|
|
// when the list takes that model in, and on the list's first model when it
|
|
// doesn't — so a model magpie picks outside it would never be used. The list
|
|
// is the user's: magpie adds the model it picks to it, as Pi's own model
|
|
// picker does, and never makes one where there is none.
|
|
|
|
// piLevels are the thinking levels a pattern may end in (provider/model:high).
|
|
var piLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"}
|
|
|
|
// piScope is settings.json's enabledModels; nil when it is missing, empty
|
|
// or not a list of strings.
|
|
func piScope(settings string) []string {
|
|
raw, ok := edit.GetJSON(settings, "enabledModels")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var pats []string
|
|
if json.Unmarshal([]byte(raw), &pats) != nil || len(pats) == 0 {
|
|
return nil
|
|
}
|
|
return pats
|
|
}
|
|
|
|
// piTakes reports whether Pi's scope pattern surely takes in ref, a
|
|
// provider/model: the same reference, a :level after it or not, or a glob
|
|
// (Pi's *, ? and [..], matched without case) that ref or its model id fits.
|
|
// A bare model id or part of one, which Pi resolves against every model it
|
|
// knows, is not counted: adding ref beside it costs nothing, since Pi drops
|
|
// a model listed twice.
|
|
func piTakes(pattern, ref string) bool {
|
|
glob := strings.ContainsAny(pattern, "*?[")
|
|
if i := strings.LastIndex(pattern, ":"); i >= 0 && slices.Contains(piLevels, pattern[i+1:]) {
|
|
pattern = pattern[:i]
|
|
}
|
|
pattern, ref = strings.ToLower(strings.TrimSpace(pattern)), strings.ToLower(ref)
|
|
if pattern == ref {
|
|
return true
|
|
}
|
|
if !glob {
|
|
return false
|
|
}
|
|
_, id, _ := strings.Cut(ref, "/")
|
|
for _, s := range []string{ref, id} {
|
|
if ok, _ := path.Match(pattern, s); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// piScopeWith adds ref to Pi's enabledModels, when there is such a list and
|
|
// nothing in it takes ref in; the user's entries stay as they are.
|
|
func piScopeWith(settings, ref string) error {
|
|
pats := piScope(settings)
|
|
if pats == nil || slices.ContainsFunc(pats, func(p string) bool { return piTakes(p, ref) }) {
|
|
return nil
|
|
}
|
|
return edit.SetJSON(settings, edit.KV{Path: "enabledModels", Value: append(pats, ref)})
|
|
}
|
|
|
|
// piScopeWithout takes magpie's models out of Pi's enabledModels once
|
|
// magpie's provider is gone from models.json, where they would name nothing.
|
|
// A list left with nothing is removed, never written empty.
|
|
func piScopeWithout(settings string) error {
|
|
pats := piScope(settings)
|
|
if pats == nil {
|
|
return nil
|
|
}
|
|
kept := slices.DeleteFunc(slices.Clone(pats), func(p string) bool {
|
|
return !strings.ContainsAny(p, "*?[") && strings.HasPrefix(strings.ToLower(p), magpieID+"/")
|
|
})
|
|
switch {
|
|
case len(kept) == len(pats):
|
|
return nil
|
|
case len(kept) == 0:
|
|
return edit.DelJSON(settings, "enabledModels")
|
|
}
|
|
return edit.SetJSON(settings, edit.KV{Path: "enabledModels", Value: kept})
|
|
}
|
|
|
|
// piStartup is the model a new Pi session starts on, as the model field
|
|
// shows it: the default when enabledModels takes it in (or there is no such
|
|
// list), else the list's first entry when that names one model outright.
|
|
func piStartup(settings, def string) string {
|
|
pats := piScope(settings)
|
|
if def == "" || pats == nil || slices.ContainsFunc(pats, func(p string) bool { return piTakes(p, def) }) {
|
|
return def
|
|
}
|
|
first := strings.TrimSpace(pats[0])
|
|
if i := strings.LastIndex(first, ":"); i >= 0 && slices.Contains(piLevels, first[i+1:]) {
|
|
first = first[:i]
|
|
}
|
|
if strings.ContainsAny(first, "*?[") || !strings.Contains(first, "/") {
|
|
return def
|
|
}
|
|
return first
|
|
}
|