lgtm set Codex · WSL Ubuntu's 推理强度 to 默认 and it came back as 中.
Two things did that, on every platform (the WSL row is the same codexIn
code as the local one):
- The effort field's Get answered an unset model_reasoning_effort on a
magpie model with codexcat.DefaultEffort (medium), so right after the
pick the slider and the row read 中, not 默认.
- settle() counted an unset effort as one the model doesn't take and
wrote DefaultEffort into config.toml: on every Sync for a magpie
model, on Join, and on a model pick, Codex's own models included. So
Default was pinned as medium, and on Codex's own models whose default
is low (gpt-6.1-sol, gpt-6-astra, gpt-5.6-sol in 0.162's models.json)
magpie overrode Codex's own default.
With model_reasoning_effort unset, Codex takes the catalog entry's
default_reasoning_level (core/src/client.rs build_reasoning). Checked
for real with codex-cli 0.162.0 in a sandbox CODEX_HOME: a
model_catalog_json entry with default_reasoning_level "low" and no
model_reasoning_effort sent {"reasoning":{"effort":"low"}} to a stand-in
Responses endpoint.
Now settle replaces only a set effort the model doesn't take, Get
returns the unset value, and the effort control's options start with
Default carrying Takes: the level Codex then takes, from the entry it
reads (codexcat.TakesEffort: Codex's own models_cache entry for its
models and a ChatGPT account's codex/ ones, else the one Entries writes).
The GUI shows it as "default (medium)" / 默认(中) / 預設(中) /
デフォルト(中) / Standard (mittel). A stopped WSL distro's options get the
same Default (codexEfforts).
Tests: TestCodexDefaultEffortStaysDefault fails on the old settle
("Default written over: model_reasoning_effort = \"high\"", "Default
shows as \"high\"") and passes now. TestCodexCustomProviderModelHasEfforts
now expects Default first with Takes "high" and an unset value read as
"". codex-default-effort.test.cjs (window at 560 and 360px, tray panel;
en, zh, ja, de; Chromium and WebKit) fails on the old effortName and
passes now.
Run: gofmt -l (clean); go vet -tags nogui on darwin, GOOS=linux and
GOOS=windows for ./internal/agent ./internal/codexcat ./internal/gui
./internal/tui and the main package; go test -tags nogui of those and
./internal/catalog under a scratch HOME (all ok); the GUI effort, gui-ja,
gui-de, gui-zh-tw and i18n-keys tests (all pass), and the whole GUI suite
in both engines: 4711 pass, 8 fail, all in library-servers-bulk
(chromium, ".lib-everyon moved from 290 to 288") and purpose-filter
(webkit), which fail the same way run alone on origin/main c1c88779.
Not run on a real WSL distro.
494 lines
18 KiB
Go
494 lines
18 KiB
Go
// Package agent describes every coding agent magpie can drive: where its config
|
||
// lives, which fields matter (model, effort, …) and which values to offer.
|
||
package agent
|
||
|
||
import (
|
||
"debug/buildinfo"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/yetone/magpie/internal/edit"
|
||
"github.com/yetone/magpie/internal/proc"
|
||
"github.com/yetone/magpie/internal/provider"
|
||
)
|
||
|
||
// Option is one value the picker offers for a field.
|
||
type Option struct {
|
||
Value string `json:"value"`
|
||
Label string `json:"label,omitempty"` // display name, when the value is an id
|
||
Note string `json:"note"`
|
||
Icon string `json:"icon,omitempty"` // bundled icon name
|
||
Icons []string `json:"icons,omitempty"` // a routing group's providers' icons, stacked
|
||
Group string `json:"group,omitempty"` // section header in the picker
|
||
// GroupIcon is the group's own logo in the picker's rail, when it is
|
||
// not the first model's (a provider serving other vendors' models)
|
||
GroupIcon string `json:"groupIcon,omitempty"`
|
||
Ref string `json:"ref,omitempty"` // the catalog model, the same in every agent
|
||
Free bool `json:"free,omitempty"` // costs its subscription nothing
|
||
// Rate and RateWas are the credits a request costs its subscription,
|
||
// as a multiple, and before a discount running now (Qoder's 0.5×,
|
||
// WorkBuddy's x0.03), when its vendor lists them
|
||
Rate float64 `json:"rate,omitempty"`
|
||
RateWas float64 `json:"rateWas,omitempty"`
|
||
// Context is the tokens the model takes, when known; the picker marks
|
||
// the large ones
|
||
Context int `json:"context,omitempty"`
|
||
// Takes, on an effort field's default (Value ""), is the level the
|
||
// agent takes for the model with none set (Codex: the catalog entry's
|
||
// default_reasoning_level), shown beside "default"
|
||
Takes string `json:"takes,omitempty"`
|
||
// Direct names who the agent asks for this model itself, on its own
|
||
// sign-in or key, with magpie not in the way ("Anthropic"): its config
|
||
// then names no magpie endpoint, which is right, not a failed setup
|
||
Direct string `json:"direct,omitempty"`
|
||
// Via is a model of the agent's own it asks through magpie's gateway,
|
||
// on its own sign-in all the same (Codex's while routed by its base
|
||
// URL): the picker says via magpie by it, as by a catalog model's
|
||
Via bool `json:"via,omitempty"`
|
||
// Same is a model magpie serves on the very account the agent is
|
||
// signed in to itself, so the agent reaches it on its own too: the
|
||
// picker folds these into one row a click opens (Claude Code, #496)
|
||
Same bool `json:"same,omitempty"`
|
||
// Alias is the value of the option a dated id is another name of
|
||
// (claude-opus-4-5-20251101 → claude-opus-4-5): the picker shows one
|
||
// row for the two, the alias, unless the dated one is the value set
|
||
Alias string `json:"alias,omitempty"`
|
||
// FastFor is the agent a catalog model with a fast mode (provider.
|
||
// CanFast) is switched fast or not for, by the model in the picker
|
||
// (#954): the one whose requests the gateway sends it on; Fast is
|
||
// whether it is now (provider.IsFastPick)
|
||
FastFor string `json:"fastFor,omitempty"`
|
||
Fast bool `json:"fast,omitempty"`
|
||
|
||
// own: served on the agent's own sign-in (viaMagpie), for Same
|
||
own bool
|
||
// sub: served on a subscription signed in in magpie, not a key; a
|
||
// Claude account only for Claude Code, which alone may use it safely
|
||
sub bool
|
||
}
|
||
|
||
// Field is one tunable setting of an agent. Set with an empty value puts
|
||
// the field back to the agent's own default: anything magpie wired in (the
|
||
// gateway as a provider, its model catalog) comes out and the key is removed.
|
||
type Field struct {
|
||
Key string
|
||
Label string
|
||
Get func() string
|
||
Set func(string) error
|
||
Options func(cur map[string]string) []Option
|
||
// Quiet fields are left out of listings while empty: they follow
|
||
// another field until set (Claude Code's per-tier models).
|
||
Quiet bool
|
||
// Follows is the key of the field a Quiet one takes after while empty
|
||
// ("model" for Claude Code's tiers and subagents): a profile's details
|
||
// say so, and Drift reads the field as on that one's model (#1050).
|
||
Follows string
|
||
}
|
||
|
||
// Agent is one supported coding agent.
|
||
type Agent struct {
|
||
ID string
|
||
Name string
|
||
Icon string // bundled icon name (internal/gui/assets/icons)
|
||
Aliases []string
|
||
Bin string // executable name, used for detection
|
||
Dir string // config directory, used for detection
|
||
Path string // config file magpie edits
|
||
Fields []Field
|
||
Native *NativeConnection
|
||
// Notice, if set, is advice worth showing after a change: agents that
|
||
// read their config once at start-up need a restart to see it.
|
||
Notice func() string
|
||
// UA is what the agent's User-Agent begins with, lower-case: how the
|
||
// gateway tells its requests from others'
|
||
UA []string
|
||
// ListsModels: the agent's own model menu is the gateway's /v1/models
|
||
// as asked with its key, so the models picked on the Agents page for it
|
||
// (settings.HiddenModels) are its menu, though no field of its picks
|
||
// among the catalog (Cursor Private Inference)
|
||
ListsModels bool
|
||
// Sync, for an agent that reads magpie's models from a file of its own
|
||
// rather than asking the gateway, rewrites that list as the catalog is
|
||
// now — where magpie wrote one; nothing else changes (see SyncCatalog).
|
||
Sync func() error
|
||
// Unwire, for an agent whose fields' default is the agent as installed
|
||
// rather than what it had before magpie (Codex, Claude Code, Gemini
|
||
// CLI), takes magpie out of its config and puts back what the stash
|
||
// kept: the endpoint, provider and model the user had. Disconnect runs
|
||
// it before the fields' defaults.
|
||
Unwire func() error
|
||
// Join, for an agent that can have magpie's models in its own list
|
||
// while it stays on the model it was on (Codex signed in with ChatGPT),
|
||
// connects it so, its model left as its own last pick; false where it
|
||
// can't, and Connect then picks one of magpie's.
|
||
Join func() (bool, error)
|
||
// Joined reports an agent Join connected: magpie is in its config
|
||
// though no field is on one of magpie's models.
|
||
Joined func() bool
|
||
// Beside reports an agent set on one of magpie's models beside its own
|
||
// (Codex by the base URL beside its ChatGPT sign-in), now on one of its
|
||
// own written in by the agent: still connected, as joined, though the
|
||
// change is told as drift (#940).
|
||
Beside func() bool
|
||
// OwnVia is the catalog id magpie serves one of the agent's own models
|
||
// by on a sign-in of the user's, which its model field lists as its
|
||
// own rather than as magpie's ("codex/gpt-5.5" for Codex's gpt-5.5),
|
||
// "" for none: Connect keeps the agent on that model through magpie
|
||
// where it can't Join (#940: Codex went to an unrelated model).
|
||
OwnVia func(model string) string
|
||
// Routed reports that the agent's config sends whatever model it
|
||
// names to magpie's gateway (Codex's openai_base_url or magpie as its
|
||
// provider), so a model's name the gateway takes as a routing group
|
||
// is that group's (#750).
|
||
Routed func() bool
|
||
// FailingOver reports an agent that isn't connected whose requests
|
||
// still go through magpie's gateway, for account failover alone (Codex
|
||
// signed in to ChatGPT with more of its accounts on in magpie, #1385):
|
||
// the Agents page says so and what turns it off, and the gateway lists
|
||
// it only its own models.
|
||
FailingOver func() bool
|
||
// Follow, for an agent whose own picker moves its main model where
|
||
// magpie keeps other settings following it (Claude Code's /model and
|
||
// its tiers), brings those along to the model picked there. Run as the
|
||
// Agents page is drawn.
|
||
Follow func() error
|
||
// RenameRefs, for an agent whose config names magpie's models beyond
|
||
// its fields (omp's other roles and fallback chains), moves those names
|
||
// off provider from onto to, the rest of each kept; it answers whether
|
||
// any moved. The fields themselves are RenameProvider's.
|
||
RenameRefs func(from, to string) (bool, error)
|
||
// Check, for an agent magpie wires in beyond its model field, says what
|
||
// of that wiring is gone while the model is still one of magpie's —
|
||
// something else rewrote the config — or "" when it is all there
|
||
// (see Drift).
|
||
Check func() string
|
||
// LastUsed, for an agent that keeps a log of its own prompts, is when
|
||
// it was last used — each prompt a request the gateway should have
|
||
// seen, so one it didn't went round magpie (see Drift). Zero if unknown.
|
||
LastUsed func() time.Time
|
||
// Reached, for an agent that logs where its model requests go, is its
|
||
// newest one since a time: when, the address it went to, and whether
|
||
// nothing answered there. Zero if there is none.
|
||
Reached func(since time.Time) (at time.Time, to string, refused bool)
|
||
// WSL is the distro an agent inside WSL lives in, "" for this
|
||
// machine's own (see wsl.go).
|
||
WSL string
|
||
// Home is a WSL agent's $HOME in its distro as magpie opens it
|
||
// (\\wsl.localhost\<distro>\home\me), where its other files are; ""
|
||
// for this machine's, and while the distro is stopped: opening it
|
||
// would start it.
|
||
Home string
|
||
// Gateway is the gateway's address as the agent reaches it, a WSL
|
||
// distro's own way to it; nil is gateway.URL.
|
||
Gateway func() string
|
||
// Import, for an app that takes magpie only through an import link of
|
||
// its own, which the user confirms there (Cindy), is that link; the app
|
||
// has no fields magpie sets. Added says whether it has magpie already.
|
||
Import func() string
|
||
Added func() bool
|
||
// Launch, for an agent that takes the gateway only from its
|
||
// environment (agy), is the command that starts it on magpie, while
|
||
// it is on one of magpie's models; "" otherwise.
|
||
Launch func() string
|
||
// SplitSuffix, for an agent whose model values carry something of its
|
||
// own after the model that varies from value to value (omp's thinking
|
||
// level, "…:max"), splits a value into the model the picker offers and
|
||
// that suffix, "" when there is none. What matches a value against the
|
||
// picker (drift, Reseat, RenameProvider, Spell) matches the model and
|
||
// puts the suffix back after the one it moves to. A mark that is always
|
||
// the same for a model (Claude Code's [1m]) rides on the option instead.
|
||
// one is false for a value that is no one model but a list the agent
|
||
// falls back through (omp's "a,b"): that is the user's own whatever it
|
||
// names, never taken for one of magpie's models nor moved by what
|
||
// matches the picker (RenameRefs moves the names in it).
|
||
SplitSuffix func(v string) (model, suffix string, one bool)
|
||
// Spelled, when set, says whether a value of the agent's fields is
|
||
// spelled as one of magpie's there (prefixed: it starts with
|
||
// "magpie/", its provider in the agent): one that isn't is a model of
|
||
// one of the agent's own providers, never magpie's, even when magpie
|
||
// has a provider of the same name (OpenHanako's own
|
||
// deepseek/deepseek-v4-pro read as magpie's deepseek, #835).
|
||
Spelled func(v string) bool
|
||
// detect, when set, says whether the agent is here in place of looking
|
||
// for its files and binary: a distro's, probed once.
|
||
detect func() bool
|
||
// reach, when set, is the gateway's address as the agent's config has
|
||
// it where that is kept apart from Gateway (this machine's Codex at an
|
||
// address of the user's, #816): Drift tries it (see reach.go).
|
||
reach func() string
|
||
// move, when set, points the agent's config at to where it names the
|
||
// gateway at from: an address of WSL's that changed (#1013).
|
||
move func(from, to string) error
|
||
// dirShared says Dir is a folder another agent keeps its files in too
|
||
// (omp's, when PI_CODING_AGENT_DIR points it at Pi's): that it is there
|
||
// says nothing of this agent.
|
||
dirShared bool
|
||
}
|
||
|
||
// Running reports whether a process whose command line matches any pattern
|
||
// (an extended regexp, as for pgrep -f) is alive. Windows can't be asked
|
||
// what runs, so anything may be: every caller is the advice an agent's own
|
||
// lists need after magpie changed what it reads at start ("restart Codex",
|
||
// "open a new dsh session"), and a Windows that answered no here dropped
|
||
// that advice silently — a model picked in magpie looked like it had done
|
||
// nothing at all. claudeRunning and Pencil's own check already say they
|
||
// can't be told, and say yes for the same reason.
|
||
func Running(patterns ...string) bool {
|
||
if runtime.GOOS == "windows" {
|
||
return len(patterns) > 0
|
||
}
|
||
for _, pat := range patterns {
|
||
if err := proc.Command("pgrep", "-f", pat).Run(); err == nil {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Detected reports whether the agent seems to be installed or configured.
|
||
func (a *Agent) Detected() bool {
|
||
if a.detect != nil {
|
||
return a.detect()
|
||
}
|
||
// a file where the agent keeps its folder is another tool's (a shell's
|
||
// ~/.dsh), and the agent can't be here: it couldn't make its folder
|
||
if a.Dir != "" && Taken(a.Dir) {
|
||
return false
|
||
}
|
||
if _, err := os.Stat(a.Path); err == nil {
|
||
return true
|
||
}
|
||
if a.Dir != "" && !a.dirShared && agentDir(a.Dir) {
|
||
return true
|
||
}
|
||
if a.Bin != "" {
|
||
if _, err := exec.LookPath(a.Bin); err == nil {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// agentDir reports whether p is a folder an agent itself made. Skill
|
||
// installers (npx skills and the like) make <folder>/skills for every agent
|
||
// they know, installed or not; a folder holding nothing else is theirs.
|
||
func agentDir(p string) bool {
|
||
es, err := os.ReadDir(p)
|
||
if err != nil {
|
||
return isDir(p)
|
||
}
|
||
if len(es) == 0 {
|
||
return true
|
||
}
|
||
for _, e := range es {
|
||
if n := e.Name(); n != "skills" && n != ".DS_Store" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Taken reports whether something that isn't a folder is where the folder
|
||
// p, or one it is in, would be: nothing can be written under it.
|
||
func Taken(p string) bool {
|
||
for d := filepath.Clean(p); ; {
|
||
if st, err := os.Stat(d); err == nil {
|
||
return !st.IsDir()
|
||
}
|
||
up := filepath.Dir(d)
|
||
if up == d {
|
||
return false
|
||
}
|
||
d = up
|
||
}
|
||
}
|
||
|
||
// goProgram reports whether bin was built by Go: another tool of the same
|
||
// name, not the agent, when the agent is not written in Go. Reading a
|
||
// binary's build info parses its whole symbol table (~150ms for a large
|
||
// one), and detection runs on every state the window asks for, so the
|
||
// answer is kept while the file is the same.
|
||
func goProgram(bin string) bool {
|
||
st, err := os.Stat(bin)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
key := goProgramKey{bin, st.Size(), st.ModTime()}
|
||
goPrograms.Lock()
|
||
defer goPrograms.Unlock()
|
||
if v, ok := goPrograms.m[key]; ok {
|
||
return v
|
||
}
|
||
_, err = buildinfo.ReadFile(bin)
|
||
if goPrograms.m == nil {
|
||
goPrograms.m = map[goProgramKey]bool{}
|
||
}
|
||
goPrograms.m[key] = err == nil
|
||
return err == nil
|
||
}
|
||
|
||
type goProgramKey struct {
|
||
path string
|
||
size int64
|
||
mod time.Time
|
||
}
|
||
|
||
var goPrograms struct {
|
||
sync.Mutex
|
||
m map[goProgramKey]bool
|
||
}
|
||
|
||
// Field looks a field up by key.
|
||
func (a *Agent) Field(key string) *Field {
|
||
for i := range a.Fields {
|
||
if a.Fields[i].Key == key {
|
||
return &a.Fields[i]
|
||
}
|
||
}
|
||
for i := range a.Fields {
|
||
if a.Fields[i].Label == key { // `magpie gemini auth …`: the label as shown
|
||
return &a.Fields[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Values reads every field.
|
||
func (a *Agent) Values() map[string]string {
|
||
m := make(map[string]string, len(a.Fields))
|
||
for _, f := range a.Fields {
|
||
m[f.Key] = f.Get()
|
||
}
|
||
return m
|
||
}
|
||
|
||
// Detected returns the agents present on this machine, in display order.
|
||
func Detected() []*Agent {
|
||
var out []*Agent
|
||
for _, a := range All() {
|
||
if a.Detected() {
|
||
out = append(out, a)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Find resolves a user-typed name (id, alias, or unique prefix).
|
||
func Find(q string) (*Agent, error) {
|
||
q = strings.ToLower(strings.TrimSpace(q))
|
||
all := All()
|
||
var prefix []*Agent
|
||
for _, a := range all {
|
||
if strings.EqualFold(a.ID, q) { // codex@wsl:Ubuntu
|
||
return a, nil
|
||
}
|
||
for _, al := range a.Aliases {
|
||
if al == q {
|
||
return a, nil
|
||
}
|
||
}
|
||
if strings.HasPrefix(a.ID, q) || strings.HasPrefix(strings.ToLower(a.Name), q) {
|
||
prefix = append(prefix, a)
|
||
}
|
||
}
|
||
switch len(prefix) {
|
||
case 1:
|
||
return prefix[0], nil
|
||
case 0:
|
||
return nil, fmt.Errorf("unknown agent %q (try: %s)", q, strings.Join(ids(all), ", "))
|
||
}
|
||
return nil, fmt.Errorf("%q is ambiguous: %s", q, strings.Join(ids(prefix), ", "))
|
||
}
|
||
|
||
func ids(as []*Agent) []string {
|
||
s := make([]string, len(as))
|
||
for i, a := range as {
|
||
s[i] = a.ID
|
||
}
|
||
sort.Strings(s)
|
||
return s
|
||
}
|
||
|
||
// Spell puts a value typed for field key the way this agent spells it: a
|
||
// catalog model is "copilot/gpt-6" in some agents and "magpie/copilot/gpt-6"
|
||
// in others, and either is taken in both. A value the picker offers as is
|
||
// stays, and so does one it doesn't know (a model the agent reaches on its
|
||
// own that isn't listed); a "magpie/…" value the catalog doesn't have is an
|
||
// error rather than a model the agent would ask its own vendor for. The
|
||
// agent's suffix after the model (SplitSuffix) stays as typed, and so does
|
||
// a list of models, the user's own.
|
||
func (a *Agent) Spell(key, v string) (string, error) {
|
||
f := a.Field(key)
|
||
if f == nil || f.Options == nil || v == "" {
|
||
return v, nil
|
||
}
|
||
model, suffix, one := a.split(v)
|
||
if !one {
|
||
return v, nil
|
||
}
|
||
opts := f.Options(a.Values())
|
||
for _, o := range opts {
|
||
if o.Value == model {
|
||
return v, nil
|
||
}
|
||
}
|
||
ref, prefixed := strings.CutPrefix(model, magpieID+"/")
|
||
// a provider renamed since (a profile saved before) is the same one
|
||
refs := []string{ref}
|
||
if r := provider.RenamedRef(ref); r != ref {
|
||
refs = append(refs, r)
|
||
}
|
||
for _, r := range refs {
|
||
for _, o := range opts {
|
||
if o.Ref != "" && o.Ref == r {
|
||
return o.Value + suffix, nil
|
||
}
|
||
}
|
||
}
|
||
if prefixed {
|
||
return "", fmt.Errorf("%s isn't a model in magpie's catalog (magpie models lists them)", ref)
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// split is v as the model the picker offers and the agent's suffix after
|
||
// it (SplitSuffix), one false for a list of models; the whole of v for an
|
||
// agent without one.
|
||
func (a *Agent) split(v string) (model, suffix string, one bool) {
|
||
if a.SplitSuffix == nil {
|
||
return v, "", true
|
||
}
|
||
return a.SplitSuffix(v)
|
||
}
|
||
|
||
// atomic makes each of an agent's field sets, and its Sync, one edit of the
|
||
// files at paths: one that fails part way puts them all back as they were,
|
||
// rather than leaving, say, Codex's config.toml with magpie's provider table
|
||
// written but its model not (#253).
|
||
func atomic(a *Agent, paths ...string) *Agent {
|
||
for i := range a.Fields {
|
||
if set := a.Fields[i].Set; set != nil {
|
||
a.Fields[i].Set = func(v string) error {
|
||
return edit.Atomically(func() error { return set(v) }, paths...)
|
||
}
|
||
}
|
||
}
|
||
if sync := a.Sync; sync != nil {
|
||
a.Sync = func() error { return edit.Atomically(sync, paths...) }
|
||
}
|
||
if follow := a.Follow; follow != nil {
|
||
a.Follow = func() error { return edit.Atomically(follow, paths...) }
|
||
}
|
||
if unwire := a.Unwire; unwire != nil {
|
||
a.Unwire = func() error { return edit.Atomically(unwire, paths...) }
|
||
}
|
||
return a
|
||
}
|