526 lines
19 KiB
Go
526 lines
19 KiB
Go
package agent
|
|
|
|
// omp (oh-my-pi, a fork of Pi) keeps its settings in ~/.omp/agent/config.yml
|
|
// (or where ompDir says its variables move it),
|
|
// the model of each role under modelRoles as "provider/model", and providers
|
|
// of the user's own in models.yml beside it. magpie adds itself there as the
|
|
// provider "magpie", keyless (auth: none), with the catalog as its models; a
|
|
// model through magpie is "magpie/<provider>/<model>", which omp matches
|
|
// whole against provider/id. The other roles, the fallback chains and the
|
|
// like may name them as well (ompRefKeys); magpie stays while any does.
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/edit"
|
|
"github.com/yetone/magpie/internal/gateway"
|
|
"github.com/yetone/magpie/internal/provider"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ompEfforts are the thinking levels omp knows.
|
|
var ompEfforts = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
|
|
|
|
// ompRefKeys are where omp's config names models: each role (a list to try
|
|
// in order, "a,b" or a sequence), the retry fallback chains (a list of
|
|
// models under each key; the key is a role's name, the only kind omp 16
|
|
// takes (agent-session.ts), or from omp 18 a model selector as well
|
|
// (retry-fallback-chains.ts) — a role's name read with them is no model of
|
|
// magpie's and is passed over), the models it cycles through (enabledModels,
|
|
// also scoped to paths) and the models of task agents. An entry may end in a
|
|
// thinking level ("magpie/deepseek/pro:max").
|
|
var ompRefKeys = []string{"modelRoles", "retry.fallbackChains", "enabledModels", "task.agentModelOverrides"}
|
|
|
|
// ompRefs calls fn on each model of a value under ompRefKeys, the spaces
|
|
// after a comma kept, and answers the value with fn's answers in place.
|
|
func ompRefs(v string, fn func(string) string) string {
|
|
parts := strings.Split(v, ",")
|
|
for i, p := range parts {
|
|
lead := len(p) - len(strings.TrimLeft(p, " \t"))
|
|
parts[i] = p[:lead] + fn(p[lead:])
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// ompLevel splits a role's model from the thinking level omp reads off its
|
|
// end, "provider/model:level" (model-resolver.ts), the level returned with
|
|
// its colon: one of the efforts, off, or auto, omp's own pick each turn.
|
|
// Anything else after a colon is part of the model id (ollama's qwen3:8b),
|
|
// and a list of models omp falls back through ("a,b:high") has no level of
|
|
// its own, each of its models its own.
|
|
func ompLevel(v string) (model, level string) {
|
|
if i := strings.LastIndexByte(v, ':'); i > 0 && !strings.Contains(v, ",") {
|
|
if l := v[i+1:]; l == "off" || l == "auto" || slices.Contains(ompEfforts, l) {
|
|
return v[:i], v[i:]
|
|
}
|
|
}
|
|
return v, ""
|
|
}
|
|
|
|
// ompSplit is omp's SplitSuffix: a role's model and its thinking level
|
|
// (ompLevel), one false for a list of models omp falls back through ("a,b",
|
|
// or a YAML list, read as one), which is the user's own whatever it names.
|
|
func ompSplit(v string) (model, level string, one bool) {
|
|
if strings.Contains(v, ",") {
|
|
return v, "", false
|
|
}
|
|
model, level = ompLevel(v)
|
|
return model, level, true
|
|
}
|
|
|
|
// ompOnMagpie: a role's value is one of magpie's models, at a level or not.
|
|
func ompOnMagpie(v string) bool {
|
|
_, _, one := ompSplit(v)
|
|
return one && usesMagpie(v)
|
|
}
|
|
|
|
// ompYAMLList: a value the stash keeps is a list as the YAML it was written
|
|
// in ("[a, b]", "- a"), which no model reads as.
|
|
func ompYAMLList(v string) bool { return strings.HasPrefix(v, "[") || strings.HasPrefix(v, "- ") }
|
|
|
|
// ompProfileName is a profile name omp takes (pi-utils dirs.ts,
|
|
// normalizeProfileName); it refuses any other.
|
|
var ompProfileName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
|
|
|
// ompDir is omp's agent folder, found as omp's pi-utils (dirs.ts) finds it:
|
|
// under ~/.omp, or ~/$PI_CONFIG_DIR; a profile's (OMP_PROFILE, else
|
|
// PI_PROFILE; "default" is none) is profiles/<name>/agent there; with none,
|
|
// PI_CODING_AGENT_DIR — the variable Pi reads — moves it, else it is
|
|
// agent there. omp takes that variable as given, without expanding "~".
|
|
func ompDir(home string) string {
|
|
root := filepath.Join(home, ".omp")
|
|
if d := os.Getenv("PI_CONFIG_DIR"); d != "" {
|
|
root = filepath.Join(home, d)
|
|
}
|
|
p, set := os.LookupEnv("OMP_PROFILE")
|
|
if !set {
|
|
p = os.Getenv("PI_PROFILE")
|
|
}
|
|
if p = strings.TrimSpace(p); p != "" && p != "default" && ompProfileName.MatchString(p) && !strings.HasSuffix(p, ".") {
|
|
return filepath.Join(root, "profiles", p, "agent")
|
|
}
|
|
if d := os.Getenv("PI_CODING_AGENT_DIR"); filepath.IsAbs(d) {
|
|
return filepath.Clean(d)
|
|
}
|
|
return filepath.Join(root, "agent")
|
|
}
|
|
|
|
func omp(home string) *Agent {
|
|
dir := ompDir(home)
|
|
// omp reads the .yml and falls back to the .yaml
|
|
pick := func(name string) string {
|
|
yml := filepath.Join(dir, name+".yml")
|
|
if _, err := os.Stat(yml); err != nil {
|
|
if _, err := os.Stat(filepath.Join(dir, name+".yaml")); err == nil {
|
|
return filepath.Join(dir, name+".yaml")
|
|
}
|
|
}
|
|
return yml
|
|
}
|
|
path := pick("config")
|
|
roleGet := func(name string) func() string {
|
|
return func() string {
|
|
k := "modelRoles." + name
|
|
if v, ok := edit.GetYAML(path, k); ok {
|
|
return v
|
|
}
|
|
// a YAML list of models omp falls back through reads as the
|
|
// comma-separated string it takes for one too: what the stash
|
|
// keeps, and a reset writes back
|
|
return strings.Join(edit.GetYAMLList(path, k), ",")
|
|
}
|
|
}
|
|
// onMagpie: a role, a fallback chain, … (ompRefKeys) still names one of
|
|
// magpie's models
|
|
onMagpie := func() (used bool, err error) {
|
|
err = edit.EditYAMLStrings(path, ompRefKeys, func(v string) string {
|
|
ompRefs(v, func(m string) string { used = used || usesMagpie(m); return m })
|
|
return v
|
|
})
|
|
return used, err
|
|
}
|
|
writeMagpie := func() error {
|
|
models := pick("models")
|
|
if _, err := os.Stat(models); err != nil {
|
|
// omp moves an older models.json to models.yml only while there is
|
|
// no models.yml, so one magpie writes first has to carry it over
|
|
if err := edit.JSONToYAML(filepath.Join(dir, "models.json"), models); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return edit.SetYAML(models, edit.KV{Path: "providers." + magpieID, Value: ompProvider()})
|
|
}
|
|
// dropMagpie takes magpie's provider out of models.yml once nothing in
|
|
// the config is on magpie; while something is (a fallback chain, another
|
|
// role), it writes it afresh instead, so a role set off magpie — applying
|
|
// again what Check found — mends the wiring that is left.
|
|
dropMagpie := func() error {
|
|
used, err := onMagpie()
|
|
switch {
|
|
case err != nil:
|
|
return err
|
|
case used:
|
|
return writeMagpie()
|
|
}
|
|
return edit.DelYAML(pick("models"), "providers."+magpieID)
|
|
}
|
|
// was are the stash's keys for the roles, one each
|
|
var was []string
|
|
// role is the field for one of omp's model roles: one of magpie's brings
|
|
// magpie's provider into models.yml, and it goes once nothing is on it.
|
|
// What the user had there before magpie took the role over is stashed,
|
|
// and resetting the role puts it back: a list of models as the YAML it
|
|
// was written in, a flow list flow and a block one block.
|
|
role := func(key, label, name string, quiet bool) Field {
|
|
k := "modelRoles." + name
|
|
get := roleGet(name)
|
|
w := "omp:" + path + ":" + k
|
|
was = append(was, w)
|
|
kept := func() string {
|
|
if v, ok := edit.GetYAML(path, k); ok {
|
|
return v
|
|
}
|
|
v, _ := edit.GetYAMLText(path, k)
|
|
return v
|
|
}
|
|
return Field{
|
|
Key: key, Label: label, Quiet: quiet,
|
|
Get: get,
|
|
Set: func(v string) error {
|
|
cur := get()
|
|
if v == "" {
|
|
if ompOnMagpie(cur) {
|
|
v = unstash(w)
|
|
}
|
|
if v == "" {
|
|
if err := edit.DelYAML(path, k); err != nil {
|
|
return err
|
|
}
|
|
return dropMagpie()
|
|
}
|
|
if ompYAMLList(v) {
|
|
if err := edit.SetYAML(path, edit.KV{Path: k, Value: edit.YAMLText(v)}); err != nil {
|
|
return err
|
|
}
|
|
return dropMagpie()
|
|
}
|
|
} else if _, level, one := ompSplit(v); one && level == "" {
|
|
// another model keeps the role's thinking level; omp
|
|
// clamps one the model lacks to the highest it has below
|
|
// it (its lowest when none is) and drops it for a model
|
|
// that doesn't reason (pi-catalog model-thinking.ts),
|
|
// never fails.
|
|
// The same model picked without one is the way to have
|
|
// none: its option with the level beside it keeps it
|
|
if model, level := ompLevel(cur); level != "" && model != v {
|
|
v += level
|
|
}
|
|
}
|
|
model, _, one := ompSplit(v)
|
|
if ref, ok := strings.CutPrefix(model, magpieID+"/"); one && ok && isMagpie(ref) {
|
|
if !ompOnMagpie(cur) {
|
|
stash(map[string]string{w: kept()})
|
|
}
|
|
if err := writeMagpie(); err != nil {
|
|
return err
|
|
}
|
|
return edit.SetYAML(path, edit.KV{Path: k, Value: v})
|
|
}
|
|
forget(w)
|
|
if err := edit.SetYAML(path, edit.KV{Path: k, Value: v}); err != nil {
|
|
return err
|
|
}
|
|
return dropMagpie()
|
|
},
|
|
Options: func(cur map[string]string) []Option {
|
|
opts := append(ompOwnOptions(pick("models"), cur[key]), viaMagpie("omp", magpieID+"/")...)
|
|
// a catalog model with a thinking level is offered as it
|
|
// reads, beside the model: that keeps the level, the model
|
|
// alone clears it
|
|
if model, level := ompLevel(cur[key]); level != "" {
|
|
if i := slices.IndexFunc(opts, func(o Option) bool { return o.Ref != "" && o.Value == model }); i >= 0 {
|
|
o := opts[i]
|
|
o.Value, o.Label = cur[key], o.Label+" · "+level[1:]
|
|
opts = slices.Insert(opts, i+1, o)
|
|
}
|
|
}
|
|
return opts
|
|
},
|
|
}
|
|
}
|
|
return &Agent{
|
|
ID: "omp", Name: "omp", Icon: "omp", Aliases: []string{"oh-my-pi"},
|
|
UA: []string{"oh-my-pi"},
|
|
Bin: "omp", Dir: dir, Path: path,
|
|
// a role's thinking level is omp's, after whichever model it is on;
|
|
// a list of models is the user's own
|
|
SplitSuffix: ompSplit,
|
|
Sync: func() error {
|
|
return syncYAML(pick("models"), "providers."+magpieID, func() any { return ompProvider() })
|
|
},
|
|
// a provider renamed takes its models' ids in models.yml with it; a
|
|
// name left on the old one omp would pass over, with a warning. So
|
|
// does what a role had before magpie (a list may name magpie's
|
|
// models), which a reset puts back
|
|
RenameRefs: func(from, to string) (bool, error) {
|
|
old, now := magpieID+"/"+from+"/", magpieID+"/"+to+"/"
|
|
move := func(v string) string {
|
|
return ompRefs(v, func(m string) string {
|
|
if rest, ok := strings.CutPrefix(m, old); ok {
|
|
return now + rest
|
|
}
|
|
return m
|
|
})
|
|
}
|
|
moved := false
|
|
if err := edit.EditYAMLStrings(path, ompRefKeys, func(v string) string {
|
|
nv := move(v)
|
|
moved = moved || nv != v
|
|
return nv
|
|
}); err != nil {
|
|
return moved, err
|
|
}
|
|
for _, w := range was {
|
|
s := stashLoad()[w]
|
|
ns := move(s)
|
|
if ompYAMLList(s) {
|
|
t, err := edit.EditYAMLTextStrings(edit.YAMLText(s), move)
|
|
if err != nil {
|
|
return moved, err
|
|
}
|
|
ns = string(t)
|
|
}
|
|
if ns != s {
|
|
stash(map[string]string{w: ns})
|
|
}
|
|
}
|
|
return moved, nil
|
|
},
|
|
Notice: func() string {
|
|
if Running(`(^|/)omp( |$)`, `@oh-my-pi/pi-coding-agent`) {
|
|
return "omp reads its settings at start-up — restart open omp sessions to use this."
|
|
}
|
|
return ""
|
|
},
|
|
Check: func() string {
|
|
// whatever keeps magpie's provider in models.yml has its wiring
|
|
// checked, a fallback chain alone as much as a role
|
|
if used, _ := onMagpie(); !used {
|
|
return ""
|
|
}
|
|
models := pick("models")
|
|
return wiringOff("omp", models, func(k string) (string, bool) { return edit.GetYAML(models, "providers."+magpieID+"."+k) },
|
|
"baseUrl", gatewayV1())
|
|
},
|
|
Fields: []Field{
|
|
role("model", "model", "default", false),
|
|
// omp's own agents run on its roles (src/task/agents.ts,
|
|
// prompts/agents): task on @task; scout and sonic on @smol;
|
|
// reviewer on @slow, also the eval tool's "slow" tier. Unset,
|
|
// task gives its agent the parent session's model, and smol
|
|
// and slow take the default role's (model-resolver.ts,
|
|
// shouldInheritDefaultBeforePriority). The designer role went
|
|
// in omp 18.1.5. smol is labelled as omp names it: "small" is
|
|
// other agents' picker of its own
|
|
role("subagent", "subagents", "task", true),
|
|
role("small", "smol", "smol", true),
|
|
role("slow", "slow", "slow", true),
|
|
{
|
|
// the thinking level sessions start with, as omp's settings save
|
|
// it; unset omp takes high. auto has omp pick a level each turn:
|
|
// not a level a model lists, so it is offered here and kept out of
|
|
// ompEfforts, which a model's thinking levels are filtered by. First,
|
|
// as omp's own picker has it (16.3.5 and 18.4.4 alike)
|
|
Key: "effort", Label: "thinking",
|
|
Get: func() string { v, _ := edit.GetYAML(path, "defaultThinkingLevel"); return v },
|
|
Set: func(v string) error {
|
|
if v == "" {
|
|
return edit.DelYAML(path, "defaultThinkingLevel")
|
|
}
|
|
return edit.SetYAML(path, edit.KV{Path: "defaultThinkingLevel", Value: v})
|
|
},
|
|
Options: func(map[string]string) []Option { return static(append([]string{"auto"}, ompEfforts...)...) },
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
type ompModel struct {
|
|
ID string `yaml:"id"`
|
|
Name string `yaml:"name,omitempty"`
|
|
API string `yaml:"api,omitempty"`
|
|
BaseURL string `yaml:"baseUrl,omitempty"`
|
|
Reasoning bool `yaml:"reasoning"`
|
|
Thinking *ompThinking `yaml:"thinking,omitempty"`
|
|
Context int `yaml:"contextWindow,omitempty"`
|
|
MaxTokens int `yaml:"maxTokens,omitempty"`
|
|
Input []string `yaml:"input,omitempty"`
|
|
}
|
|
|
|
type ompThinking struct {
|
|
Mode string `yaml:"mode"`
|
|
Efforts []string `yaml:"efforts"`
|
|
}
|
|
|
|
type ompProviderEntry struct {
|
|
BaseURL string `yaml:"baseUrl"`
|
|
API string `yaml:"api"`
|
|
Auth string `yaml:"auth"`
|
|
// Headers name omp to the gateway: omp 16.x asks with Bun's User-Agent
|
|
// and only a later one with its own (omp/18.4.4), so its requests went
|
|
// to "Bun" in usage and past omp's own rules and stand-ins
|
|
Headers map[string]string `yaml:"headers,omitempty"`
|
|
Models []ompModel `yaml:"models"`
|
|
}
|
|
|
|
// ompMaxSince is the first omp whose models.yml takes max as a thinking
|
|
// effort (pi-ai 16.4.0); 16.3.5's schema stops at xhigh and turns the whole
|
|
// file away over a max.
|
|
const ompMaxSince = "16.4.0"
|
|
|
|
// ompVersion is the version of the omp on PATH, "" when not known; a var so
|
|
// tests can fake it.
|
|
var ompVersion = func() string { return (&Agent{ID: "omp", Bin: "omp"}).InstalledVersion() }
|
|
|
|
// ompTakesMax says whether omp at version v takes max in models.yml. One
|
|
// whose version isn't known is taken for an older one: a max it refuses
|
|
// costs every model magpie gives it, an xhigh in its place only the top
|
|
// level of a model that has both.
|
|
func ompTakesMax(v string) bool {
|
|
return v != "" && !Newer(ompMaxSince, v)
|
|
}
|
|
|
|
// ompProvider is magpie's entry in models.yml. The thinking efforts are the
|
|
// levels omp offers for the model; on Chat it sends them as
|
|
// reasoning_effort.
|
|
//
|
|
// As for Pi, each model is asked on the API its provider speaks natively, so
|
|
// the gateway relays what omp sent as it is instead of translating Chat: a
|
|
// Claude over Chat lost its thinking's signatures between tool turns, as
|
|
// Chat has no place for them. One served on OpenAI's Responses API goes to
|
|
// baseUrl/responses; one on Anthropic's Messages API to the gateway's
|
|
// /v1/messages (omp adds the /v1). That one thinks adaptively when it takes
|
|
// nothing else, else on a budget: omp's anthropic-budget-effort would also
|
|
// send output_config.effort, which Sonnet 4.5 and Haiku 4.5 refuse.
|
|
func ompProvider() ompProviderEntry {
|
|
takesMax := ompTakesMax(ompVersion())
|
|
ms := []ompModel{}
|
|
for _, m := range magpieModels("omp") {
|
|
e := ompModel{ID: m.ID, Name: m.Name, Context: m.Context, MaxTokens: maxTokens(m)}
|
|
mode := "effort"
|
|
switch {
|
|
case slices.Contains(m.APIs, string(provider.Responses)):
|
|
e.API = "openai-responses"
|
|
case slices.Contains(m.APIs, string(provider.Anthropic)):
|
|
e.API, e.BaseURL = "anthropic-messages", gateway.URL()
|
|
mode = "budget"
|
|
if gateway.AdaptiveThinking(m.ID) {
|
|
mode = "anthropic-adaptive"
|
|
}
|
|
}
|
|
// Without input omp takes it from a bundled model its fuzzy id match
|
|
// finds, else text only; a model the source never answered for is
|
|
// left to that guess.
|
|
switch {
|
|
case m.Images:
|
|
e.Input = []string{"text", "image"}
|
|
case m.ImageInput != nil:
|
|
e.Input = []string{"text"}
|
|
}
|
|
var efforts []string
|
|
for _, x := range ompEfforts { // in omp's order
|
|
// before omp 16.4.0 a model's efforts stop at xhigh (16.3.5
|
|
// turns the whole file away over a max): a model whose top is
|
|
// max offers xhigh, which the gateway fits to max when the
|
|
// model has no xhigh of its own
|
|
if x == "max" && !takesMax {
|
|
continue
|
|
}
|
|
if slices.Contains(m.Efforts, x) || x == "xhigh" && !takesMax && slices.Contains(m.Efforts, "max") {
|
|
efforts = append(efforts, x)
|
|
}
|
|
}
|
|
if len(efforts) > 0 {
|
|
e.Reasoning = true
|
|
e.Thinking = &ompThinking{Mode: mode, Efforts: efforts}
|
|
}
|
|
ms = append(ms, e)
|
|
}
|
|
return ompProviderEntry{BaseURL: gatewayV1(), API: "openai-completions", Auth: "none",
|
|
Headers: map[string]string{"User-Agent": "omp"}, Models: ms}
|
|
}
|
|
|
|
// ompOwnOptions lists the models of the providers the user added to omp's
|
|
// models.yml, then the ones models.dev knows for the current value's
|
|
// provider, spelled as ownOptions spells them (the model's name as the
|
|
// note). A value offered twice is offered once, with the name and icon
|
|
// either one had. A provider in both stays one group, where it first comes:
|
|
// the picker draws a heading at each change of group. A provider with only
|
|
// discovery has its models listed by omp asking it at run time, out of
|
|
// magpie's sight, so it offers none here.
|
|
func ompOwnOptions(modelsFile, cur string) []Option {
|
|
var f struct {
|
|
Providers map[string]struct {
|
|
Models []struct {
|
|
ID string `yaml:"id"`
|
|
Name string `yaml:"name"`
|
|
} `yaml:"models"`
|
|
} `yaml:"providers"`
|
|
}
|
|
if b, err := os.ReadFile(modelsFile); err == nil {
|
|
yaml.Unmarshal(b, &f)
|
|
}
|
|
providers := make([]string, 0, len(f.Providers))
|
|
for p := range f.Providers {
|
|
if p != magpieID {
|
|
providers = append(providers, p)
|
|
}
|
|
}
|
|
sort.Strings(providers)
|
|
var opts []Option
|
|
for _, p := range providers {
|
|
name := catalog.ProviderName(p)
|
|
if name == "" {
|
|
name = p
|
|
}
|
|
for _, m := range f.Providers[p].Models {
|
|
if m.ID != "" {
|
|
opts = append(opts, Option{Value: p + "/" + m.ID, Note: m.Name, Icon: modelIcon(p, m.ID), Group: name, GroupIcon: providerIcon(p)})
|
|
}
|
|
}
|
|
}
|
|
at := map[string]int{}
|
|
var out []Option
|
|
for _, o := range append(opts, ownOptions("", cur)...) {
|
|
i, dup := at[o.Value]
|
|
if !dup {
|
|
at[o.Value] = len(out)
|
|
out = append(out, o)
|
|
continue
|
|
}
|
|
if out[i].Note == "" {
|
|
out[i].Note = o.Note
|
|
}
|
|
if out[i].Icon == "" {
|
|
out[i].Icon = o.Icon
|
|
}
|
|
if out[i].GroupIcon == "" {
|
|
out[i].GroupIcon = o.GroupIcon
|
|
}
|
|
}
|
|
first := map[string]int{}
|
|
for i, o := range out {
|
|
if _, ok := first[o.Group]; !ok {
|
|
first[o.Group] = i
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return first[out[i].Group] < first[out[j].Group] })
|
|
return out
|
|
}
|