673 lines
29 KiB
Go
673 lines
29 KiB
Go
// Package settings keeps the few preferences the desktop app has: which
|
|
// palette to paint with, which language to speak, how the agents are
|
|
// arranged. Everything else magpie knows is derived from the agents' own
|
|
// files.
|
|
//
|
|
// The file is ~/.config/magpie/settings.json; a missing file means "follow
|
|
// the system" for both.
|
|
package settings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/redact"
|
|
)
|
|
|
|
// Settings is what the user chose. "" and "system" both mean "follow the OS".
|
|
type Settings struct {
|
|
Theme string `json:"theme,omitempty"` // system | light | dark
|
|
Lang string `json:"lang,omitempty"` // system | en | zh
|
|
Tray string `json:"tray,omitempty"` // what clicking the tray icon opens: panel | window
|
|
// SessionTerminal is the Mac app that opens a resumed session, by bundle
|
|
// id. "" and "system" follow the .command file association.
|
|
SessionTerminal string `json:"sessionTerminal,omitempty"`
|
|
// Currency is what a cost — the Usage page's, the tray panel's, the
|
|
// TUI's and the CLI's — is shown converted to: usd (its native
|
|
// currency, list prices being in dollars) or cny, at a live exchange
|
|
// rate (see internal/fx). A vendor's own balance, already in its own
|
|
// currency (a Chinese relay's ¥), is never touched by this.
|
|
Currency string `json:"currency,omitempty"`
|
|
// WesternUnits shortens a large count in K, M and B even when magpie
|
|
// speaks Chinese, which otherwise says it in 万 and 亿 (8000 万
|
|
// rather than 80M). It means nothing in English.
|
|
WesternUnits bool `json:"westernUnits,omitempty"`
|
|
// Dock keeps magpie in the Mac's Dock as well as the menu bar, for a
|
|
// menu bar too full to show its icon.
|
|
Dock bool `json:"dock,omitempty"`
|
|
// DockWindow shows it in the Dock only while its window is open, so
|
|
// Cmd-Tab reaches the window without an icon kept there the rest of
|
|
// the time. Dock wins over it.
|
|
DockWindow bool `json:"dockWindow,omitempty"`
|
|
// Proxy for magpie's own requests to vendors: "" follows the
|
|
// environment and then the system, "direct" uses none, anything else
|
|
// is the proxy (http://, https:// or socks5://; host:port means http).
|
|
Proxy string `json:"proxy,omitempty"`
|
|
OTel OTel `json:"otel,omitempty"`
|
|
// Redact keeps secrets in what agents send (API keys, private keys,
|
|
// tokens, passwords) from the vendors behind magpie: they go as
|
|
// placeholders, and come back as they were. RedactPersonal does the same
|
|
// for emails, phone numbers and ID and bank card numbers, and
|
|
// RedactWords for the user's own words. RedactRules are the user's own
|
|
// rules for secrets magpie's don't know (a gateway's oc_sk_… key), a
|
|
// prefix or a pattern each, masked with the secrets while Redact is on.
|
|
Redact bool `json:"redact,omitempty"`
|
|
RedactPersonal bool `json:"redactPersonal,omitempty"`
|
|
RedactWords []string `json:"redactWords,omitempty"`
|
|
RedactRules []redact.Rule `json:"redactRules,omitempty"`
|
|
// LAN shares the gateway on the local network; remote callers must use
|
|
// named caller keys. LANKey is retained for older Magpie versions.
|
|
LAN bool `json:"lan,omitempty"`
|
|
LANKey string `json:"lanKey,omitempty"`
|
|
// LANKeyID remembers the default named key created when sharing is enabled.
|
|
LANKeyID string `json:"lanKeyId,omitempty"`
|
|
// RequestArchive keeps each call the gateway serves — its headers and
|
|
// bodies both ways, secrets taken out — in the S3 bucket sync keeps
|
|
// its backup in (gateway/archive.go), for looking into a request later.
|
|
RequestArchive bool `json:"requestArchive,omitempty"`
|
|
// RequestArchiveMaxMB is how much of each body the archive keeps, in
|
|
// MiB: 0 for 32, at most 1024 (#447)
|
|
RequestArchiveMaxMB int `json:"requestArchiveMaxMB,omitempty"`
|
|
// CodexWarmup starts a ChatGPT account's next window as soon as the
|
|
// last one resets, with one tiny request, so it counts from then (a
|
|
// Codex window starts at its first use): "" off, "week" the weekly
|
|
// window, "all" the 5-hour one too.
|
|
CodexWarmup string `json:"codexWarmup,omitempty"`
|
|
// ClaudeWarmup is CodexWarmup for the Claude accounts, the request
|
|
// sent through Claude Code.
|
|
ClaudeWarmup string `json:"claudeWarmup,omitempty"`
|
|
// CodexWarmAt starts each ChatGPT account's 5-hour window at a time of
|
|
// day of the user's choosing, local "15:04", with the same tiny
|
|
// request: an account whose 5-hour window isn't running then gets one,
|
|
// so the windows line up with the day (06:00 gives three by 21:00, where
|
|
// the first use at 9 gives two by the end of it); "" off. It works
|
|
// with CodexWarmup or without it. ClaudeWarmAt is the Claude accounts'.
|
|
CodexWarmAt string `json:"codexWarmAt,omitempty"`
|
|
ClaudeWarmAt string `json:"claudeWarmAt,omitempty"`
|
|
// CodexAutoReset are the ChatGPT accounts (lower-case) that spend one
|
|
// of their rate-limit resets by themselves once their weekly window is
|
|
// used up and no other account can take the request: at most one a
|
|
// week each (see provider.AutoUseCodexReset).
|
|
CodexAutoReset []string `json:"codexAutoReset,omitempty"`
|
|
// WorkBuddyCheckin presses WorkBuddy's daily check-in (签到) for each
|
|
// signed-in WorkBuddy (China) account once a Beijing day, claiming the
|
|
// credits it gives while its event runs.
|
|
WorkBuddyCheckin bool `json:"workbuddyCheckin,omitempty"`
|
|
// NoStats stops the one event a day that counts magpie's users (see
|
|
// internal/stats).
|
|
NoStats bool `json:"noStats,omitempty"`
|
|
// NoUpdatePill keeps the header's Update pill away when a newer magpie
|
|
// is out; UpdateSkip is the one version it was hidden for, and a newer
|
|
// one brings it back. Either way magpie still downloads the version and
|
|
// puts it in as it quits, and Settings' version row still offers it.
|
|
NoUpdatePill bool `json:"noUpdatePill,omitempty"`
|
|
UpdateSkip string `json:"updateSkip,omitempty"`
|
|
// NoAutoUpdate stops magpie asking for a newer version by itself, and
|
|
// so downloading one (#472): only Settings' Check, or magpie update,
|
|
// asks then. UpdateEvery is how often it asks otherwise, in minutes:
|
|
// one of UpdateEveries, 0 for every six hours.
|
|
NoAutoUpdate bool `json:"noAutoUpdate,omitempty"`
|
|
UpdateEvery int `json:"updateEvery,omitempty"`
|
|
// Vision is the model that describes an image to a model that can't see
|
|
// it: a model's id (provider/model, group/<id>), "off" to turn such an
|
|
// image away, or empty for one magpie picks (see gateway.seer).
|
|
Vision string `json:"vision,omitempty"`
|
|
// ImageGen is the model magpie's generate_image tool draws with (the
|
|
// gateway's /v1/images/generations when a request names no model): a
|
|
// model's id, "off", or empty for one magpie picks (gateway.drawer).
|
|
// "off" turns the video a request that names no model gets off too
|
|
// (gateway.videomaker).
|
|
ImageGen string `json:"imageGen,omitempty"`
|
|
// TrayUsages are the subscriptions and plans whose windows are shown
|
|
// beside the tray icon, in the order shown, each by its provider and
|
|
// account ("claude|a@b.c"); none when empty.
|
|
TrayUsages []string `json:"trayUsages,omitempty"`
|
|
// TrayUsage is TrayUsages' first, all a magpie before them read: a file
|
|
// with it and no TrayUsages shows that one, and it is kept written for
|
|
// an older magpie to go on showing it.
|
|
TrayUsage string `json:"trayUsage,omitempty"`
|
|
// TrayUsageEvery is how often, in minutes, that text is brought up to
|
|
// date; 0 is every 3 (one of TrayEvery).
|
|
TrayUsageEvery int `json:"trayUsageEvery,omitempty"`
|
|
// TrayNoLogos draws the Mac menu bar's cards without their logos: each
|
|
// is its windows stacked alone, a thin line between one card and the
|
|
// next.
|
|
TrayNoLogos bool `json:"trayNoLogos,omitempty"`
|
|
// QuotaLeft shows a subscription's windows by how much of each is left,
|
|
// not used: the Usage page, the tray panel and the menu bar alike.
|
|
QuotaLeft bool `json:"quotaLeft,omitempty"`
|
|
// UsageAlert is how much of a subscription's or plan's window, in
|
|
// percent, is used when magpie says so with a notification (#368):
|
|
// once for each time the window runs, for every window routing counts
|
|
// (not one set aside, as on-demand spending is); 0 is off.
|
|
UsageAlert int `json:"usageAlert,omitempty"`
|
|
// BalanceAlert is the balance a key or account has fallen to when
|
|
// magpie says so, in that balance's own currency or credits, once until
|
|
// it is topped up past it again; 0 is off.
|
|
BalanceAlert float64 `json:"balanceAlert,omitempty"`
|
|
// PlainNames has the model lists magpie gives agents name each model
|
|
// by its name alone, without its provider's or "routing group" after it
|
|
// (#335) — but for two in one list that would read the same, which keep
|
|
// it (see provider.Labels).
|
|
PlainNames bool `json:"plainNames,omitempty"`
|
|
// PlainOwnNames, with PlainNames off, has those lists name a model the
|
|
// user gave a name of their own by that name alone, just as they wrote
|
|
// it (#92: "Opus 5.5", not "Opus 5.5 · Claude Code"), the vendor's names
|
|
// keeping their provider's after them (see provider.Labels).
|
|
PlainOwnNames bool `json:"plainOwnNames,omitempty"`
|
|
// TextSize is how large the window's and the tray panel's pages are
|
|
// drawn, in percent (one of TextSizes): the webviews' own zoom, as a
|
|
// browser's, so the text and everything around it grow together.
|
|
TextSize int `json:"textSize,omitempty"`
|
|
// How the agents are listed, by agent id. AgentOrder comes first, as
|
|
// ordered; an agent it doesn't name (one installed since) follows in
|
|
// magpie's own order. A hidden agent is folded away at the bottom of the
|
|
// list; a shown one stays in view even while nothing is set on it, which
|
|
// otherwise folds it away too. The agents' own files never hear of it.
|
|
AgentOrder []string `json:"agentOrder,omitempty"`
|
|
AgentsHidden []string `json:"agentsHidden,omitempty"`
|
|
AgentsShown []string `json:"agentsShown,omitempty"`
|
|
// Visible narrows the models an agent is shown, by agent id: the
|
|
// families (the tag a provider or group is given), provider ids and
|
|
// group ids its lists hold. An agent it doesn't name is shown them all.
|
|
Visible map[string][]string `json:"visible,omitempty"`
|
|
// HiddenModels are the catalog entries (their ids, "<provider>/<model>"
|
|
// or a group's) taken out of an agent's lists one by one, by agent id,
|
|
// after Visible: a model not named here, a new one among them, is shown.
|
|
HiddenModels map[string][]string `json:"hiddenModels,omitempty"`
|
|
|
|
// The three maps below, and every one added beside them, are the
|
|
// per-model ones: a field named Model* whose type is a map[string]X,
|
|
// keyed "<provider id>/<model id>" (or "<provider id>/*", for all of
|
|
// that provider's models — CheckModelKey). That is the whole of the
|
|
// convention, and it is what RenamePerModel and the GUI's saving of
|
|
// the settings go by, each of them walking the fields by it rather
|
|
// than by a list kept up to date by hand: a map added here later is
|
|
// moved when a provider is renamed and kept when another page saves
|
|
// the settings, with no line written for it in either place. A field
|
|
// whose keys are not a model's is not one of these, and is not named
|
|
// Model* — Visible, which is by agent id, among them.
|
|
// ModelNames are the names the user gave models, by "<provider
|
|
// id>/<model id>": agents, the gateway's model list and magpie itself
|
|
// show them for the vendor's (see provider.SetModelName).
|
|
ModelNames map[string]string `json:"modelNames,omitempty"`
|
|
// ModelEfforts are the reasoning levels the user keeps of a model's,
|
|
// by "<provider id>/<model id>": the lists magpie hands out offer only
|
|
// those (see provider.SetModelEfforts).
|
|
ModelEfforts map[string][]string `json:"modelEfforts,omitempty"`
|
|
// ModelImages is whether the user said a model takes images, by
|
|
// "<provider id>/<model id>". Absent leaves it to the vendor's list.
|
|
ModelImages map[string]bool `json:"modelImages,omitempty"`
|
|
// ModelPrices is what a model costs the user, in USD per million
|
|
// tokens, by "<provider id>/<model id>", and "*" for every model of
|
|
// that provider: a provider models.dev does not list, or one that
|
|
// resells at a multiplier, is otherwise priced at whatever its maker's
|
|
// list price is. "*/<model id>" (AnyProvider) is that model from any
|
|
// provider not priced above, and a session's model named with no
|
|
// provider at all: one whose provider is gone, or one models.dev no
|
|
// longer lists.
|
|
ModelPrices map[string]ModelPrice `json:"modelPrices,omitempty"`
|
|
// ModelOutputs is the most a reply of a model may hold, by "<provider
|
|
// id>/<model id>", and "*" for every model of that provider. Absent
|
|
// leaves it to the vendor's own list and to models.dev, as a context
|
|
// the user has not set is. A model's own value wins over its
|
|
// provider's, and the agents' own files are told of either
|
|
// (see provider.SetModelOutput).
|
|
ModelOutputs map[string]int `json:"modelOutputs,omitempty"`
|
|
// ModelWires is the name to send a vendor for a model magpie knows by
|
|
// another, by "<provider id>/<model id>", and "*" for every model of that
|
|
// provider. A "*" in the name is the model itself, so one name covers a
|
|
// relay that namespaces its models — vendor-c/* asks for model-3 as
|
|
// vendor-c/model-3 — while a name with no "*" in it sends every model of
|
|
// that key under that one name. Everything else — the catalog agents see,
|
|
// the routing groups, the usage records and what a call is priced at —
|
|
// keeps the name magpie knows the model by.
|
|
ModelWires map[string]string `json:"modelWires,omitempty"`
|
|
// The main window's size when it was last resized, width and height,
|
|
// so it opens at it again after a restart.
|
|
Window []int `json:"window,omitempty"`
|
|
}
|
|
|
|
// ModelPrice is the price of one model as the user states it. Each part is a
|
|
// pointer so that a file leaving one out is told so rather than billing that
|
|
// part of the call at zero: a model is priced whole or not at all.
|
|
type ModelPrice struct {
|
|
Input *float64 `json:"input,omitempty"`
|
|
Output *float64 `json:"output,omitempty"`
|
|
CacheRead *float64 `json:"cache_read,omitempty"`
|
|
CacheWrite *float64 `json:"cache_write,omitempty"`
|
|
}
|
|
|
|
// priceParts are the parts of a price, in the order they are asked for and
|
|
// in the order ModelPrice holds them, named as a message about one says
|
|
// them: "cache read" and "cache write", the words the CLI and the README
|
|
// use, not the cache_read and cache_write of the file's own keys, which
|
|
// are the disk format and stay as they are.
|
|
var priceParts = [...]string{"input", "output", "cache read", "cache write"}
|
|
|
|
// Price is the price the user stated, when every part of it is given and is a
|
|
// number a vendor could charge. The second return names the first part that is
|
|
// not, empty when the price is usable.
|
|
func (m ModelPrice) Price() (catalog.Price, string) {
|
|
parts := []*float64{m.Input, m.Output, m.CacheRead, m.CacheWrite}
|
|
for i, v := range parts {
|
|
if v == nil || math.IsNaN(*v) || math.IsInf(*v, 0) || *v < 0 {
|
|
return catalog.Price{}, priceParts[i]
|
|
}
|
|
}
|
|
return catalog.Price{
|
|
Input: *m.Input, Output: *m.Output,
|
|
CacheRead: *m.CacheRead, CacheWrite: *m.CacheWrite,
|
|
}, ""
|
|
}
|
|
|
|
// CheckModelPrice is whether a price the user gives is one magpie will bill a
|
|
// call at: the key names a model the way the per-model maps key one, and
|
|
// every part of the price is a number a vendor could charge, naming the first
|
|
// that is not. It is where a price is checked rather than in Save: one a hand
|
|
// edit left broken in the file is skipped where it is read, and must not stop
|
|
// an unrelated setting from being saved. That covers a part that is missing,
|
|
// which is what a file can be edited into: JSON has no NaN or Infinity, and
|
|
// Load discards the whole file rather than half of it, so neither reaches
|
|
// here. Both are still refused, from a price typed in or passed in.
|
|
func CheckModelPrice(key string, m ModelPrice) error {
|
|
if model, every := strings.CutPrefix(key, AnyProvider); every {
|
|
if model == "" || model == "*" {
|
|
return fmt.Errorf("a price for every provider names one model, such as */claude-opus-4.6, not %q", key)
|
|
}
|
|
} else if err := CheckModelKey("price", key); err != nil {
|
|
return err
|
|
}
|
|
if _, bad := m.Price(); bad != "" {
|
|
return fmt.Errorf("the price of %q needs %s %s price that is present, finite and not negative", key, anArticle(bad), bad)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AnyProvider begins a ModelPrices key that prices a model whichever
|
|
// provider it came through, its id lower-cased after it.
|
|
const AnyProvider = "*/"
|
|
|
|
// anArticle is the article a word takes where a message names it, so that an
|
|
// input price is not "a input price".
|
|
func anArticle(word string) string {
|
|
if strings.ContainsRune("aeiou", rune(word[0])) {
|
|
return "an"
|
|
}
|
|
return "a"
|
|
}
|
|
|
|
// Arrange puts items in the order the user gave the agents, those named
|
|
// first and the rest after in the order they came, and splits off the
|
|
// hidden ones, which keep that order too. id names an item's agent.
|
|
func Arrange[T any](s Settings, items []T, id func(T) string) (shown, hidden []T) {
|
|
rank := map[string]int{}
|
|
for i, x := range s.AgentOrder {
|
|
if _, dup := rank[x]; !dup {
|
|
rank[x] = i
|
|
}
|
|
}
|
|
sorted := slices.Clone(items)
|
|
slices.SortStableFunc(sorted, func(a, b T) int {
|
|
ra, oka := rank[id(a)]
|
|
rb, okb := rank[id(b)]
|
|
switch {
|
|
case oka && okb:
|
|
return ra - rb
|
|
case oka:
|
|
return -1
|
|
case okb:
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
for _, x := range sorted {
|
|
if slices.Contains(s.AgentsHidden, id(x)) {
|
|
hidden = append(hidden, x)
|
|
} else {
|
|
shown = append(shown, x)
|
|
}
|
|
}
|
|
return shown, hidden
|
|
}
|
|
|
|
// Themes and Langs are the accepted values, in the order the UI offers them.
|
|
var (
|
|
Themes = []string{"system", "light", "dark"}
|
|
Langs = []string{"system", "en", "zh"}
|
|
Trays = []string{"panel", "window"}
|
|
Currencies = []string{"usd", "cny"}
|
|
// Warmups are CodexWarmup's and ClaudeWarmup's values, off as "".
|
|
Warmups = []string{"", "week", "all"}
|
|
// TrayEvery are TrayUsageEvery's values, in minutes.
|
|
TrayEvery = []int{1, 3, 5, 10, 30}
|
|
// UpdateEveries are UpdateEvery's values, in minutes.
|
|
UpdateEveries = []int{30, 60, 360, 1440}
|
|
// TextSizes are TextSize's values, in percent. None is under 100: the
|
|
// webviews' zoom on Windows and Linux (Wails' SetZoom) goes no lower.
|
|
TextSizes = []int{100, 110, 125, 150}
|
|
)
|
|
|
|
var validTerminalBundleID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$`)
|
|
|
|
// providerID is how a provider's id is spelled: lower-case letters, digits
|
|
// and dashes, as provider.Slug derives it (a custom provider's id is the
|
|
// same, the site it is on when its name has none).
|
|
var providerID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
|
|
// CheckModelKey is whether a key of one of the per-model maps names a model
|
|
// the way all of them do: "<provider id>/<model id>" — or "<provider id>/*"
|
|
// for all of that provider's models. A model id may have slashes of its own
|
|
// (vendor/model), so only the first one ends the provider's id. what names
|
|
// the setting being checked, for the error.
|
|
//
|
|
// It is the one rule a model key is held to, and it lives here, beside the
|
|
// maps it keys. Every change that takes such a key from the user checks it
|
|
// here rather than spelling the shape out again, so what is written into a
|
|
// map and what is refused come out of the same rule — one rule, and not one
|
|
// each branch can drift from on its own.
|
|
func CheckModelKey(what, key string) error {
|
|
pid, model, ok := strings.Cut(key, "/")
|
|
if !ok || model == "" {
|
|
return fmt.Errorf("%s must be a model as <provider>/<model>, such as openai/gpt-5-mini, or <provider>/*, not %q", what, key)
|
|
}
|
|
if !providerID.MatchString(pid) {
|
|
return fmt.Errorf("%s must name a provider before the model's id, such as openai/gpt-5-mini, not %q", what, key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// perModelFields are the fields of t that are per-model: those named Model*
|
|
// whose type is a map[string]X (see ModelNames). The name and the type are
|
|
// all that is looked at, so a field the caller means by another key is taken
|
|
// for a per-model map all the same, and a field of a type that is not
|
|
// map[string]X is left out however it is named.
|
|
func perModelFields(t reflect.Type) []reflect.StructField {
|
|
var out []reflect.StructField
|
|
for i := range t.NumField() {
|
|
f := t.Field(i)
|
|
if strings.HasPrefix(f.Name, "Model") && f.Type.Kind() == reflect.Map && f.Type.Key().Kind() == reflect.String {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PerModelKeys calls fn with the name of every per-model map of s and the
|
|
// map itself, the settings' own values and not copies, so fn may change them
|
|
// (see ModelNames). A field that is not per-model is not passed; the name
|
|
// comes with the map so a caller that reports on the maps it was given can
|
|
// tell them apart.
|
|
func PerModelKeys(s *Settings, fn func(name string, m reflect.Value)) {
|
|
v := reflect.ValueOf(s).Elem()
|
|
for _, f := range perModelFields(v.Type()) {
|
|
fn(f.Name, v.FieldByIndex(f.Index))
|
|
}
|
|
}
|
|
|
|
// CarryPerModel puts cur's per-model maps into in's, whole as they are: the
|
|
// ones a page that sends only its own choices would otherwise save as
|
|
// nothing, and so lose. Every per-model map is carried, whichever page wrote
|
|
// it, so a map added to the settings later needs nothing said of it here.
|
|
func CarryPerModel(in, cur *Settings) {
|
|
dst, src := reflect.ValueOf(in).Elem(), reflect.ValueOf(cur).Elem()
|
|
for _, f := range perModelFields(dst.Type()) {
|
|
dst.FieldByIndex(f.Index).Set(src.FieldByIndex(f.Index))
|
|
}
|
|
}
|
|
|
|
// KeepOwn puts back cur's settings that are this computer's own, which a
|
|
// sync or a restored backup never brings from another: the window's size,
|
|
// the proxy, the Dock, and what the menu bar or tray shows beside magpie's
|
|
// icon (yoooo on Discord: usage turned off on a Mac came back from a
|
|
// Windows box that shows it).
|
|
func (s *Settings) KeepOwn(cur Settings) {
|
|
s.Window, s.Proxy, s.Dock, s.DockWindow = cur.Window, cur.Proxy, cur.Dock, cur.DockWindow
|
|
s.TrayUsages, s.TrayUsage, s.TrayUsageEvery, s.TrayNoLogos = cur.TrayUsages, cur.TrayUsage, cur.TrayUsageEvery, cur.TrayNoLogos
|
|
}
|
|
|
|
// RenamePerModel moves what the user said of a provider's models to the id
|
|
// it has now: in every per-model map (see ModelNames) each key beginning
|
|
// with from+"/" is rewritten to to+"/", and it says whether any key moved at
|
|
// all. A provider that changes its id keeps the names, levels, image
|
|
// answers and everything else given to its models, each map moved whether or
|
|
// not the ones before it moved anything.
|
|
func (s *Settings) RenamePerModel(from, to string) bool {
|
|
moved := false
|
|
PerModelKeys(s, func(_ string, m reflect.Value) {
|
|
if renameInMap(m, from, to) {
|
|
moved = true
|
|
}
|
|
})
|
|
return moved
|
|
}
|
|
|
|
// renameInMap is RenamePerModel for one of the maps: every key of from+"/..."
|
|
// is written as to+"/...", and it says whether one moved. The new key is
|
|
// built as a string and then converted to the map's own key type, because a
|
|
// field keyed by a named string type (map[modelKey]X) is per-model all the
|
|
// same, and a plain string is not assignable to one. Every per-model field
|
|
// the settings have today is keyed by string, so nothing but a test on such
|
|
// a type reaches that conversion.
|
|
func renameInMap(m reflect.Value, from, to string) bool {
|
|
moved := false
|
|
for _, k := range m.MapKeys() {
|
|
rest, ok := strings.CutPrefix(k.String(), from+"/")
|
|
if !ok {
|
|
continue
|
|
}
|
|
v := m.MapIndex(k)
|
|
m.SetMapIndex(k, reflect.Value{})
|
|
m.SetMapIndex(reflect.ValueOf(to+"/"+rest).Convert(m.Type().Key()), v)
|
|
moved = true
|
|
}
|
|
return moved
|
|
}
|
|
|
|
// Path is the settings file.
|
|
func Path() string {
|
|
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
|
return filepath.Join(x, "magpie", "settings.json")
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".config", "magpie", "settings.json")
|
|
}
|
|
|
|
// Dir is the folder every magpie file lives in.
|
|
func Dir() string { return filepath.Dir(Path()) }
|
|
|
|
// Load reads the settings; anything missing or unreadable is the default.
|
|
func Load() Settings {
|
|
var s Settings
|
|
if b, err := os.ReadFile(Path()); err == nil {
|
|
_ = json.Unmarshal(b, &s)
|
|
}
|
|
return s.normal()
|
|
}
|
|
|
|
// CheckProxy says whether p is a proxy setting magpie takes: "" (follow),
|
|
// "direct", or an http://, https:// or socks5:// address (host:port
|
|
// meaning http). The global Proxy and a provider's own are both checked
|
|
// with it.
|
|
func CheckProxy(p string) error {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" || p == "direct" {
|
|
return nil
|
|
}
|
|
raw := p
|
|
if !strings.Contains(raw, "://") {
|
|
raw = "http://" + raw
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" || !slices.Contains([]string{"http", "https", "socks5", "socks5h"}, u.Scheme) {
|
|
return fmt.Errorf("proxy must look like http://127.0.0.1:7890 or socks5://127.0.0.1:1080, not %q", p)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Save validates and writes the settings.
|
|
func Save(s Settings) error {
|
|
s = s.normal()
|
|
if !slices.Contains(Themes, s.Theme) {
|
|
return fmt.Errorf("theme must be one of %v, not %q", Themes, s.Theme)
|
|
}
|
|
if !slices.Contains(Langs, s.Lang) {
|
|
return fmt.Errorf("language must be one of %v, not %q", Langs, s.Lang)
|
|
}
|
|
if !slices.Contains(Trays, s.Tray) {
|
|
return fmt.Errorf("tray must be one of %v, not %q", Trays, s.Tray)
|
|
}
|
|
if s.SessionTerminal != "" && s.SessionTerminal != "system" && !validTerminalBundleID.MatchString(s.SessionTerminal) {
|
|
return fmt.Errorf("session terminal must be an app bundle id or system, not %q", s.SessionTerminal)
|
|
}
|
|
if !slices.Contains(Currencies, s.Currency) {
|
|
return fmt.Errorf("currency must be one of %v, not %q", Currencies, s.Currency)
|
|
}
|
|
if !slices.Contains(Warmups, s.CodexWarmup) {
|
|
return fmt.Errorf("codex warm-up must be off, week or all, not %q", s.CodexWarmup)
|
|
}
|
|
if !slices.Contains(Warmups, s.ClaudeWarmup) {
|
|
return fmt.Errorf("claude warm-up must be off, week or all, not %q", s.ClaudeWarmup)
|
|
}
|
|
for _, at := range []string{s.CodexWarmAt, s.ClaudeWarmAt} {
|
|
if _, _, ok := Clock(at); at != "" && !ok {
|
|
return fmt.Errorf("a warm-up's time of day must look like 06:00, not %q", at)
|
|
}
|
|
}
|
|
if !slices.Contains(TrayEvery, s.TrayUsageEvery) {
|
|
return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery)
|
|
}
|
|
if !slices.Contains(UpdateEveries, s.UpdateEvery) {
|
|
return fmt.Errorf("magpie checks for updates every %v minutes, not %d", UpdateEveries, s.UpdateEvery)
|
|
}
|
|
if s.UsageAlert < 0 || s.UsageAlert > 100 {
|
|
return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or 0 for off, not %d", s.UsageAlert)
|
|
}
|
|
if math.IsNaN(s.BalanceAlert) || math.IsInf(s.BalanceAlert, 0) || s.BalanceAlert < 0 {
|
|
return fmt.Errorf("a balance alert is at an amount of 0 or more (0 for off), not %v", s.BalanceAlert)
|
|
}
|
|
if !slices.Contains(TextSizes, s.TextSize) {
|
|
return fmt.Errorf("text size must be one of %v percent, not %d", TextSizes, s.TextSize)
|
|
}
|
|
s.OTel.Endpoint = strings.TrimRight(strings.TrimSpace(s.OTel.Endpoint), "/")
|
|
if err := s.OTel.Check(); err != nil {
|
|
return err
|
|
}
|
|
s.Proxy = strings.TrimSpace(s.Proxy)
|
|
if err := CheckProxy(s.Proxy); err != nil {
|
|
return err
|
|
}
|
|
s.Vision = strings.TrimSpace(s.Vision)
|
|
if s.Vision != "" && s.Vision != "off" && !strings.Contains(s.Vision, "/") {
|
|
return fmt.Errorf("the vision model must be a model's id such as openai/gpt-5-mini, or off, not %q", s.Vision)
|
|
}
|
|
s.ImageGen = strings.TrimSpace(s.ImageGen)
|
|
if s.ImageGen != "" && s.ImageGen != "off" && !strings.Contains(s.ImageGen, "/") {
|
|
return fmt.Errorf("the image generation model must be a model's id such as openai/gpt-image-1, or off, not %q", s.ImageGen)
|
|
}
|
|
rules, err := redact.CheckRules(s.RedactRules)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.RedactRules = rules
|
|
s.AgentOrder, s.AgentsHidden, s.AgentsShown = ids(s.AgentOrder), ids(s.AgentsHidden), ids(s.AgentsShown)
|
|
s.TrayUsages = ids(s.TrayUsages)
|
|
for i, u := range s.CodexAutoReset {
|
|
s.CodexAutoReset[i] = strings.ToLower(u)
|
|
}
|
|
s.CodexAutoReset = ids(s.CodexAutoReset)
|
|
s.TrayUsage = ""
|
|
if len(s.TrayUsages) > 0 {
|
|
s.TrayUsage = s.TrayUsages[0]
|
|
}
|
|
if err := os.MkdirAll(Dir(), 0o755); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Restrict existing settings without changing the owner's permissions.
|
|
if fi, err := os.Stat(Path()); err == nil && fi.Mode().Perm()&0o077 != 0 {
|
|
if err := os.Chmod(Path(), fi.Mode().Perm()&0o700); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return os.WriteFile(Path(), append(b, '\n'), 0o600)
|
|
}
|
|
|
|
func (s Settings) normal() Settings {
|
|
if s.Theme == "" {
|
|
s.Theme = "system"
|
|
}
|
|
if s.Lang == "" {
|
|
s.Lang = "system"
|
|
}
|
|
if s.Tray == "" {
|
|
s.Tray = "panel"
|
|
}
|
|
if s.Currency == "" {
|
|
s.Currency = "usd"
|
|
}
|
|
if s.CodexWarmup == "off" {
|
|
s.CodexWarmup = ""
|
|
}
|
|
if s.ClaudeWarmup == "off" {
|
|
s.ClaudeWarmup = ""
|
|
}
|
|
if s.TrayUsageEvery == 0 {
|
|
s.TrayUsageEvery = 3
|
|
}
|
|
if s.UpdateEvery == 0 {
|
|
s.UpdateEvery = 360
|
|
}
|
|
if s.TextSize == 0 {
|
|
s.TextSize = 100
|
|
}
|
|
// one card, as a magpie before TrayUsages kept it; an empty list
|
|
// sent on purpose (all of them turned off) stays empty
|
|
if s.TrayUsages == nil && s.TrayUsage != "" {
|
|
s.TrayUsages = []string{s.TrayUsage}
|
|
}
|
|
// a time of day as 06:00 whichever way it came (6:00, 06:00:00)
|
|
for _, at := range []*string{&s.CodexWarmAt, &s.ClaudeWarmAt} {
|
|
*at = strings.TrimSpace(*at)
|
|
if h, m, ok := Clock(*at); ok {
|
|
*at = fmt.Sprintf("%02d:%02d", h, m)
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Clock reads a time of day, "06:00" (seconds, as a time field may send
|
|
// them, are dropped), as its hour and minute.
|
|
func Clock(at string) (hour, min int, ok bool) {
|
|
for _, layout := range []string{"15:04", "15:04:05"} {
|
|
if t, err := time.Parse(layout, at); err == nil {
|
|
return t.Hour(), t.Minute(), true
|
|
}
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
// ids trims, drops empties and repeats, and keeps the first of each.
|
|
func ids(in []string) []string {
|
|
var out []string
|
|
for _, x := range in {
|
|
if x = strings.TrimSpace(x); x != "" && !slices.Contains(out, x) {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return out
|
|
}
|