666 lines
21 KiB
Go
666 lines
21 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"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"
|
|
"github.com/yetone/magpie/internal/usage"
|
|
)
|
|
|
|
// The gateway knows each agent's requests by what this package says of it.
|
|
func init() {
|
|
usage.Agents = func() []usage.Known {
|
|
var out []usage.Known
|
|
for _, a := range Clients() {
|
|
out = append(out, usage.Known{ID: a.ID, Names: append([]string{a.ID}, a.Aliases...), UA: a.UA})
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// others are clients that reach the gateway without being agents magpie
|
|
// sets up: known only by their requests, to be drawn with a logo.
|
|
var others = []*Agent{
|
|
{ID: "magpie", Name: "magpie", Icon: "magpie", UA: []string{"magpie"}},
|
|
{ID: "curl", Name: "curl", Icon: "curl", UA: []string{"curl"}},
|
|
}
|
|
|
|
// Clients is everyone whose requests the gateway knows by name: every
|
|
// agent, detected or not, and the others. It is what the Usage and
|
|
// Routing views draw a request's client with.
|
|
func Clients() []*Agent {
|
|
return append(All(), others...)
|
|
}
|
|
|
|
// All returns every agent magpie knows about, detected or not.
|
|
func All() []*Agent {
|
|
home, _ := os.UserHomeDir()
|
|
cfg := os.Getenv("XDG_CONFIG_HOME")
|
|
if cfg == "" {
|
|
cfg = filepath.Join(home, ".config")
|
|
}
|
|
return []*Agent{
|
|
claude(home),
|
|
codex(home),
|
|
gemini(home),
|
|
opencode(home, cfg),
|
|
mimocode(home, cfg),
|
|
pi(home),
|
|
goose(home, cfg),
|
|
cursor(home),
|
|
copilot(home),
|
|
crush(home, cfg),
|
|
dsh(home),
|
|
commandCode(home),
|
|
omp(home),
|
|
devin(home, cfg),
|
|
hermes(home),
|
|
cline(home),
|
|
qoder(home),
|
|
qoderCN(home),
|
|
grok(home),
|
|
zcode(home),
|
|
hanako(home),
|
|
alma(),
|
|
}
|
|
}
|
|
|
|
// ---- accessors -------------------------------------------------------------
|
|
|
|
func jsonGet(path, key string) func() string {
|
|
return func() string { v, _ := edit.GetJSON(path, key); return v }
|
|
}
|
|
|
|
func jsonSet(path, key string) func(string) error {
|
|
return func(v string) error {
|
|
if v == "" {
|
|
return edit.DelJSON(path, key)
|
|
}
|
|
return edit.SetJSON(path, edit.KV{Path: key, Value: v})
|
|
}
|
|
}
|
|
|
|
// usesMagpie reports whether any of the values is a magpie/… reference.
|
|
func usesMagpie(vals ...string) bool {
|
|
for _, v := range vals {
|
|
if strings.HasPrefix(v, magpieID+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// pair joins a provider field and a model field into one "provider/model"
|
|
// value, which is how OpenCode already spells it and how people think of it.
|
|
func pairGet(get func(string) (string, bool), pKey, mKey string) func() string {
|
|
return func() string {
|
|
p, _ := get(pKey)
|
|
m, _ := get(mKey)
|
|
switch {
|
|
case m == "":
|
|
return ""
|
|
case p == "":
|
|
return m
|
|
}
|
|
return p + "/" + m
|
|
}
|
|
}
|
|
|
|
func pairSet(set func(...edit.KV) error, pKey, mKey string) func(string) error {
|
|
return func(v string) error {
|
|
p, m, ok := strings.Cut(v, "/")
|
|
if !ok || p == "" || m == "" {
|
|
return fmt.Errorf("expected provider/model, got %q", v)
|
|
}
|
|
return set(edit.KV{Path: pKey, Value: p}, edit.KV{Path: mKey, Value: m})
|
|
}
|
|
}
|
|
|
|
func hostOf(u string) string { return provider.HostOf(u) }
|
|
|
|
// ---- option builders -------------------------------------------------------
|
|
|
|
func options(models []catalog.Model, prefix string) []Option {
|
|
out := make([]Option, 0, len(models))
|
|
for _, m := range models {
|
|
out = append(out, Option{Value: prefix + m.ID, Note: m.Name, Icon: modelIcon(m.Provider, m.ID)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func static(vals ...string) []Option {
|
|
out := make([]Option, len(vals))
|
|
for i, v := range vals {
|
|
out[i] = Option{Value: v}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ownOptions lists provider/model pairs an agent reaches on its own: the
|
|
// providers in its auth file, plus whatever the current value already uses.
|
|
func ownOptions(authFile string, cur string, extra ...string) []Option {
|
|
set := map[string]bool{}
|
|
for _, p := range extra {
|
|
set[p] = true
|
|
}
|
|
if p, _, ok := strings.Cut(cur, "/"); ok && p != magpieID {
|
|
set[p] = true
|
|
}
|
|
if b, err := os.ReadFile(authFile); err == nil {
|
|
var m map[string]json.RawMessage
|
|
if json.Unmarshal(b, &m) == nil {
|
|
for k := range m {
|
|
set[k] = true
|
|
}
|
|
}
|
|
}
|
|
providers := make([]string, 0, len(set))
|
|
for p := range set {
|
|
providers = append(providers, p)
|
|
}
|
|
sort.Strings(providers)
|
|
var out []Option
|
|
for _, p := range providers {
|
|
name := catalog.ProviderName(p)
|
|
if name == "" {
|
|
name = p
|
|
}
|
|
opts := group(name, options(catalog.Provider(p), p+"/"))
|
|
if ic := providerIcon(p); ic != "" {
|
|
for i := range opts {
|
|
opts[i].GroupIcon = ic
|
|
}
|
|
}
|
|
out = append(out, opts...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- agents ----------------------------------------------------------------
|
|
|
|
// magpieProviderJSON is the provider block agents with JSON configs get.
|
|
func magpieProviderJSON(shape string) any {
|
|
return magpieProviderJSONFor(shape, shape)
|
|
}
|
|
|
|
// magpieProviderJSONFor is magpieProviderJSON for an agent whose catalog is
|
|
// narrowed under a different id than its config shape (mimocode shares
|
|
// OpenCode's shape but is seen as itself by magpie visible).
|
|
func magpieProviderJSONFor(shape, catalog string) any {
|
|
models := magpieModels(catalog) // the catalog is the agent's
|
|
switch shape {
|
|
case "opencode":
|
|
ms := map[string]any{}
|
|
for _, m := range models {
|
|
e := map[string]any{"name": m.Name}
|
|
if m.Images {
|
|
e["attachment"] = true
|
|
e["modalities"] = map[string]any{"input": []string{"text", "image"}, "output": []string{"text"}}
|
|
}
|
|
// without it OpenCode doesn't know when to compact, and a
|
|
// group's context (magpie group set … context=) never reaches
|
|
// it; an output of 0 is OpenCode's own default
|
|
if m.Context > 0 {
|
|
e["limit"] = map[string]any{"context": m.Context, "output": m.Output}
|
|
}
|
|
ms[m.ID] = e
|
|
}
|
|
return map[string]any{"npm": "@ai-sdk/openai-compatible", "name": "magpie",
|
|
"options": map[string]any{"baseURL": gatewayV1(), "apiKey": gateway.Token}, "models": ms}
|
|
case "crush":
|
|
var ms []map[string]any
|
|
for _, m := range models {
|
|
window := m.Context
|
|
if window == 0 {
|
|
window = 200000
|
|
}
|
|
ms = append(ms, map[string]any{"id": m.ID, "name": m.Name, "context_window": window, "default_max_tokens": 16384,
|
|
"can_reason": len(m.Efforts) > 0})
|
|
}
|
|
if ms == nil {
|
|
ms = []map[string]any{}
|
|
}
|
|
return map[string]any{"type": "openai", "name": "magpie", "base_url": gatewayV1(), "api_key": gateway.Token, "models": ms}
|
|
case "pi":
|
|
var ms []map[string]any
|
|
for _, m := range models {
|
|
// reasoning lets Pi offer its thinking levels for the model
|
|
e := map[string]any{"id": m.ID, "name": m.Name, "reasoning": len(m.Efforts) > 0}
|
|
if m.Images {
|
|
e["input"] = []string{"text", "image"}
|
|
}
|
|
if levels := piThinkingLevels(m.Efforts); levels != nil {
|
|
e["thinkingLevelMap"] = levels
|
|
}
|
|
// without it Pi takes every model for a 128K one, and compacts
|
|
// a 272K or 922K one long before it has to
|
|
if m.Context > 0 {
|
|
e["contextWindow"] = m.Context
|
|
}
|
|
// without it Pi caps every reply at 16384 tokens, a model
|
|
// that can write 128K included
|
|
if m.Output > 0 {
|
|
e["maxTokens"] = m.Output
|
|
}
|
|
ms = append(ms, e)
|
|
}
|
|
if ms == nil {
|
|
ms = []map[string]any{}
|
|
}
|
|
return map[string]any{"name": "magpie", "baseUrl": gatewayV1(), "api": "openai-completions", "apiKey": gateway.Token, "models": ms}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// piThinkingLevels is the thinkingLevelMap for a model's efforts. Pi offers
|
|
// xhigh and max only for models that map them, so without it a model whose
|
|
// top level is max stopped at high in Pi.
|
|
func piThinkingLevels(efforts []string) map[string]any {
|
|
var levels map[string]any
|
|
for _, e := range efforts {
|
|
if e == "xhigh" || e == "max" {
|
|
if levels == nil {
|
|
levels = map[string]any{}
|
|
}
|
|
levels[e] = e
|
|
}
|
|
}
|
|
return levels
|
|
}
|
|
|
|
// openCodeLike is OpenCode and the forks that keep its config shape
|
|
// (mimocode): a provider block with npm/@ai-sdk/openai-compatible, model and
|
|
// small_model fields, and its own auth file beside its config.
|
|
func openCodeLike(id, name, icon, bin, dir, auth string, ua []string, aliases ...string) *Agent {
|
|
path := filepath.Join(dir, id+".json")
|
|
if _, err := os.Stat(filepath.Join(dir, id+".jsonc")); err == nil {
|
|
path = filepath.Join(dir, id+".jsonc")
|
|
}
|
|
provider := func() any { return magpieProviderJSONFor("opencode", id) }
|
|
opts := func(key string) func(map[string]string) []Option {
|
|
return func(cur map[string]string) []Option {
|
|
return append(ownOptions(auth, cur[key]), viaMagpie(id, magpieID+"/")...)
|
|
}
|
|
}
|
|
get := func(k string) string { v, _ := edit.GetJSON(path, k); return v }
|
|
set := func(key string) func(string) error {
|
|
return func(v string) error {
|
|
if v == "" {
|
|
if err := edit.DelJSON(path, key); err != nil {
|
|
return err
|
|
}
|
|
if usesMagpie(get("model"), get("small_model")) {
|
|
return nil
|
|
}
|
|
return edit.DelJSON(path, "provider."+magpieID)
|
|
}
|
|
if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) {
|
|
// a provider of the file's own already sends this model to
|
|
// magpie: name it there, rather than add a second list of
|
|
// the same models under magpie's
|
|
if own := ownGatewayProvider(path, ref); own != "" {
|
|
return edit.SetJSON(path, edit.KV{Path: key, Value: own + "/" + ref})
|
|
}
|
|
if err := edit.SetJSON(path, edit.KV{Path: "provider." + magpieID, Value: provider()}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return edit.SetJSON(path, edit.KV{Path: key, Value: v})
|
|
}
|
|
}
|
|
return &Agent{
|
|
ID: id, Name: name, Icon: icon, Aliases: aliases,
|
|
UA: ua,
|
|
Bin: bin, Dir: dir, Path: path,
|
|
Check: func() string {
|
|
if !usesMagpie(get("model"), get("small_model")) {
|
|
return ""
|
|
}
|
|
return wiringOff(name, path, func(k string) (string, bool) { return edit.GetJSON(path, "provider."+magpieID+".options."+k) },
|
|
"baseURL", gatewayV1(), "apiKey", gateway.Token)
|
|
},
|
|
Sync: func() error {
|
|
// a model of magpie's chosen, but its provider gone from the
|
|
// file: put it back, or the agent has nothing to send it to
|
|
if _, ok := edit.GetJSON(path, "provider."+magpieID); !ok && usesMagpie(get("model"), get("small_model")) {
|
|
return edit.SetJSON(path, edit.KV{Path: "provider." + magpieID, Value: provider()})
|
|
}
|
|
return syncJSON(path, "provider."+magpieID, provider)
|
|
},
|
|
Fields: []Field{
|
|
{Key: "model", Label: "model", Get: jsonGet(path, "model"), Set: set("model"), Options: opts("model")},
|
|
{Key: "small", Label: "small", Get: jsonGet(path, "small_model"), Set: set("small_model"), Options: opts("small")},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ownGatewayProvider is the provider in an OpenCode config, other than
|
|
// magpie's own, whose baseURL is magpie's gateway and that lists the model
|
|
// ref, as a layout of one provider per family of magpie's models has; ""
|
|
// when there is none.
|
|
func ownGatewayProvider(path, ref string) string {
|
|
raw, ok := edit.GetJSON(path, "provider")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var ps map[string]struct {
|
|
Options struct {
|
|
BaseURL string `json:"baseURL"`
|
|
} `json:"options"`
|
|
Models map[string]json.RawMessage `json:"models"`
|
|
}
|
|
if json.Unmarshal([]byte(raw), &ps) != nil {
|
|
return ""
|
|
}
|
|
names := make([]string, 0, len(ps))
|
|
for name := range ps {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
p := ps[name]
|
|
if _, has := p.Models[ref]; name != magpieID && has && sameGateway(p.Options.BaseURL) {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sameGateway reports whether a base URL is magpie's gateway's, its host
|
|
// spelled 127.0.0.1 or localhost.
|
|
func sameGateway(base string) bool {
|
|
norm := func(u string) string {
|
|
u = strings.TrimRight(strings.TrimSpace(u), "/")
|
|
return strings.Replace(u, "://localhost:", "://127.0.0.1:", 1)
|
|
}
|
|
return base != "" && norm(base) == norm(gatewayV1())
|
|
}
|
|
|
|
func opencode(home, cfg string) *Agent {
|
|
return openCodeLike("opencode", "OpenCode", "opencode", "opencode",
|
|
filepath.Join(cfg, "opencode"), filepath.Join(home, ".local", "share", "opencode", "auth.json"),
|
|
[]string{"opencode"}, "oc")
|
|
}
|
|
|
|
func mimocode(home, cfg string) *Agent {
|
|
return openCodeLike("mimocode", "MiMo Code", "mimocode", "mimo",
|
|
filepath.Join(cfg, "mimocode"), filepath.Join(home, ".local", "share", "mimocode", "auth.json"),
|
|
[]string{"mimocode"}, "mimo")
|
|
}
|
|
|
|
func pi(home string) *Agent {
|
|
dir := filepath.Join(home, ".pi", "agent")
|
|
path := filepath.Join(dir, "settings.json")
|
|
modelsPath := filepath.Join(dir, "models.json")
|
|
auth := filepath.Join(dir, "auth.json")
|
|
get := func(k string) (string, bool) { return edit.GetJSON(path, k) }
|
|
set := func(kvs ...edit.KV) error { return edit.SetJSON(path, kvs...) }
|
|
pair := pairSet(set, "defaultProvider", "defaultModel")
|
|
writeMagpie := func() error {
|
|
return edit.SetJSON(modelsPath, edit.KV{Path: "providers." + magpieID, Value: magpieProviderJSON("pi")})
|
|
}
|
|
return &Agent{
|
|
ID: "pi", Name: "Pi", Icon: "pi", Bin: "pi", Dir: dir, Path: path,
|
|
UA: []string{"pi-"},
|
|
Check: func() string {
|
|
if p, _ := get("defaultProvider"); p != magpieID {
|
|
return ""
|
|
}
|
|
return wiringOff("Pi", modelsPath, func(k string) (string, bool) { return edit.GetJSON(modelsPath, "providers."+magpieID+"."+k) },
|
|
"baseUrl", gatewayV1(), "apiKey", gateway.Token)
|
|
},
|
|
Sync: func() error {
|
|
return syncJSON(modelsPath, "providers."+magpieID, func() any { return magpieProviderJSON("pi") })
|
|
},
|
|
Fields: []Field{
|
|
{
|
|
Key: "model", Label: "model",
|
|
Get: pairGet(get, "defaultProvider", "defaultModel"),
|
|
Set: func(v string) error {
|
|
if v == "" {
|
|
if err := edit.DelJSON(path, "defaultProvider", "defaultModel"); err != nil {
|
|
return err
|
|
}
|
|
return edit.DelJSON(modelsPath, "providers."+magpieID)
|
|
}
|
|
if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) {
|
|
if err := writeMagpie(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return pair(v)
|
|
},
|
|
Options: func(cur map[string]string) []Option {
|
|
return append(ownOptions(auth, cur["model"]), viaMagpie("pi", magpieID+"/")...)
|
|
},
|
|
},
|
|
{
|
|
// Pi's startup thinking level, the same list its /thinking offers;
|
|
// Pi clamps it to what the model supports.
|
|
Key: "effort", Label: "thinking",
|
|
Get: func() string { v, _ := get("defaultThinkingLevel"); return v },
|
|
Set: func(v string) error {
|
|
if v == "" {
|
|
return edit.DelJSON(path, "defaultThinkingLevel")
|
|
}
|
|
if p, _ := get("defaultProvider"); p == magpieID {
|
|
// older magpie entries lacked "reasoning", which Pi needs
|
|
// before it will think at all
|
|
if err := writeMagpie(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return set(edit.KV{Path: "defaultThinkingLevel", Value: v})
|
|
},
|
|
Options: func(map[string]string) []Option {
|
|
return static("off", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func goose(home, cfg string) *Agent {
|
|
path := filepath.Join(cfg, "goose", "config.yaml")
|
|
if runtime.GOOS == "windows" {
|
|
if app := os.Getenv("APPDATA"); app != "" {
|
|
path = filepath.Join(app, "Block", "goose", "config", "config.yaml")
|
|
}
|
|
}
|
|
get := func(k string) (string, bool) { return edit.GetYAMLTop(path, k) }
|
|
set := func(kvs ...edit.KV) error { return edit.SetYAMLTop(path, kvs...) }
|
|
return &Agent{
|
|
ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path,
|
|
UA: []string{"goose"},
|
|
Fields: []Field{{
|
|
Key: "model", Label: "model",
|
|
Get: pairGet(get, "GOOSE_PROVIDER", "GOOSE_MODEL"),
|
|
Set: func(v string) error {
|
|
if v == "" {
|
|
return edit.DelYAMLTop(path, "GOOSE_PROVIDER", "GOOSE_MODEL")
|
|
}
|
|
return pairSet(set, "GOOSE_PROVIDER", "GOOSE_MODEL")(v)
|
|
},
|
|
Options: func(cur map[string]string) []Option {
|
|
return ownOptions("", cur["model"], "anthropic", "openai", "google", "openrouter")
|
|
},
|
|
}, {
|
|
// GOOSE_THINKING_EFFORT, the effort goose asks of a model that
|
|
// thinks, for every provider
|
|
Key: "effort", Label: "effort",
|
|
Get: func() string { v, _ := get("GOOSE_THINKING_EFFORT"); return v },
|
|
Set: func(v string) error {
|
|
if v == "" {
|
|
return edit.DelYAMLTop(path, "GOOSE_THINKING_EFFORT")
|
|
}
|
|
return set(edit.KV{Path: "GOOSE_THINKING_EFFORT", Value: v})
|
|
},
|
|
Options: func(map[string]string) []Option {
|
|
return static("off", "low", "medium", "high", "max")
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func cursor(home string) *Agent {
|
|
path := filepath.Join(home, ".cursor", "cli-config.json")
|
|
return &Agent{
|
|
ID: "cursor", Name: "Cursor", Icon: "cursor", Aliases: []string{"cursor-agent"},
|
|
UA: []string{"cursor"},
|
|
Bin: "cursor-agent", Dir: filepath.Dir(path), Path: path,
|
|
Fields: []Field{{
|
|
Key: "model", Label: "model",
|
|
Get: jsonGet(path, "model.modelId"),
|
|
Set: func(v string) error {
|
|
if v == "" {
|
|
return edit.DelJSON(path, "model", "hasChangedDefaultModel")
|
|
}
|
|
return edit.SetJSON(path,
|
|
edit.KV{Path: "model.modelId", Value: v},
|
|
edit.KV{Path: "model.displayModelId", Value: v},
|
|
edit.KV{Path: "model.displayName", Value: v},
|
|
edit.KV{Path: "hasChangedDefaultModel", Value: true},
|
|
)
|
|
},
|
|
Options: func(map[string]string) []Option {
|
|
return []Option{{Value: "auto", Note: "let Cursor pick", Icon: "cursor"}}
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func copilot(home string) *Agent {
|
|
dir := filepath.Join(home, ".copilot")
|
|
path := filepath.Join(dir, "settings.json")
|
|
return &Agent{
|
|
ID: "copilot", Name: "Copilot CLI", Icon: "githubcopilot", Aliases: []string{"gh-copilot"},
|
|
UA: []string{"copilot", "github-copilot"},
|
|
Bin: "copilot", Dir: dir, Path: path,
|
|
Fields: []Field{{
|
|
Key: "model", Label: "model",
|
|
Get: jsonGet(path, "model"),
|
|
Set: jsonSet(path, "model"),
|
|
Options: func(map[string]string) []Option {
|
|
// "auto" is Copilot's own choice, not a model it lists; the rest
|
|
// come from the list magpie last fetched from Copilot.
|
|
out := []Option{{Value: "auto", Note: "let Copilot pick", Icon: "githubcopilot"}}
|
|
if live, _, ok := catalog.Live("copilot"); ok {
|
|
out = append(out, options(live, "")...)
|
|
}
|
|
return out
|
|
},
|
|
}, {
|
|
// effortLevel, which Copilot saves beside the model and clears
|
|
// when its own /model changes the model; the levels are the
|
|
// model's, low to xhigh when Copilot's list does not say
|
|
Key: "effort", Label: "effort",
|
|
Get: jsonGet(path, "effortLevel"),
|
|
Set: jsonSet(path, "effortLevel"),
|
|
Options: func(cur map[string]string) []Option {
|
|
if live, _, ok := catalog.Live("copilot"); ok {
|
|
if e := catalog.Efforts(live, cur["model"]); len(e) > 0 {
|
|
return static(e...)
|
|
}
|
|
}
|
|
return static("low", "medium", "high", "xhigh")
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func crush(home, cfg string) *Agent {
|
|
path := filepath.Join(cfg, "crush", "crush.json")
|
|
if runtime.GOOS == "windows" {
|
|
if app := os.Getenv("LOCALAPPDATA"); app != "" {
|
|
path = filepath.Join(app, "crush", "crush.json")
|
|
}
|
|
}
|
|
get := func(k string) (string, bool) { return edit.GetJSON(path, k) }
|
|
set := func(kvs ...edit.KV) error { return edit.SetJSON(path, kvs...) }
|
|
opts := func(key string) func(map[string]string) []Option {
|
|
return func(cur map[string]string) []Option {
|
|
var extra []string
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
var c struct {
|
|
Providers map[string]json.RawMessage `json:"providers"`
|
|
}
|
|
if json.Unmarshal(b, &c) == nil {
|
|
for p := range c.Providers {
|
|
if p != magpieID {
|
|
extra = append(extra, p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return append(ownOptions("", cur[key], extra...), viaMagpie("crush", magpieID+"/")...)
|
|
}
|
|
}
|
|
setter := func(pKey, mKey string) func(string) error {
|
|
pair := pairSet(set, pKey, mKey)
|
|
return func(v string) error {
|
|
if v == "" {
|
|
if err := edit.DelJSON(path, strings.TrimSuffix(pKey, ".provider")); err != nil {
|
|
return err
|
|
}
|
|
large := pairGet(get, "models.large.provider", "models.large.model")()
|
|
small := pairGet(get, "models.small.provider", "models.small.model")()
|
|
if usesMagpie(large, small) {
|
|
return nil
|
|
}
|
|
return edit.DelJSON(path, "providers."+magpieID)
|
|
}
|
|
if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) {
|
|
if err := set(edit.KV{Path: "providers." + magpieID, Value: magpieProviderJSON("crush")}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return pair(v)
|
|
}
|
|
}
|
|
return &Agent{
|
|
ID: "crush", Name: "Crush", Icon: "crush", Bin: "crush", Dir: filepath.Dir(path), Path: path,
|
|
UA: []string{"crush"},
|
|
Check: func() string {
|
|
large, _ := get("models.large.provider")
|
|
small, _ := get("models.small.provider")
|
|
if large != magpieID && small != magpieID {
|
|
return ""
|
|
}
|
|
return wiringOff("Crush", path, func(k string) (string, bool) { return get("providers." + magpieID + "." + k) },
|
|
"base_url", gatewayV1(), "api_key", gateway.Token)
|
|
},
|
|
Sync: func() error {
|
|
return syncJSON(path, "providers."+magpieID, func() any { return magpieProviderJSON("crush") })
|
|
},
|
|
Fields: []Field{
|
|
{Key: "model", Label: "large", Get: pairGet(get, "models.large.provider", "models.large.model"), Set: setter("models.large.provider", "models.large.model"), Options: opts("model")},
|
|
{Key: "small", Label: "small", Get: pairGet(get, "models.small.provider", "models.small.model"), Set: setter("models.small.provider", "models.small.model"), Options: opts("small")},
|
|
{
|
|
// the large model's reasoning_effort, which Crush's schema
|
|
// takes as low, medium or high (for OpenAI-style models)
|
|
Key: "effort", Label: "effort",
|
|
Get: func() string { v, _ := get("models.large.reasoning_effort"); return v },
|
|
Set: func(v string) error {
|
|
if v == "" {
|
|
return edit.DelJSON(path, "models.large.reasoning_effort")
|
|
}
|
|
if m, _ := get("models.large.model"); m == "" {
|
|
return fmt.Errorf("pick Crush's large model first; the effort is kept with it")
|
|
}
|
|
return set(edit.KV{Path: "models.large.reasoning_effort", Value: v})
|
|
},
|
|
Options: func(map[string]string) []Option { return static("low", "medium", "high") },
|
|
},
|
|
},
|
|
}
|
|
}
|