agent: Snow CLI is an agent magpie sets up: a magpie profile in ~/.snow, made the active one, that Snow App takes in with "Sync Snow CLI API config" (#1457)
Snow CLI (npm snow-ai, MIT, MayDay-wpf/snow-cli) keeps one profile per file under ~/.snow/profiles (or $SNOW_CONFIG_DIR), names the active one in active-profile.json, and copies it over config.json at every start, headless `snow --ask` included. Each profile is the whole snowcfg: base URL, key, request method and models. Snow App (MayDay-wpf/snow-app) reads ~/.snow/profiles/*.json when "Sync Snow CLI API config" is pressed. magpie writes profiles/magpie.json from the profile the user was on (their settings kept, Snow's OAuth sign-in left out): baseUrlMode base at the gateway's /v1, requestMethod chat, the agent's key, the model as advancedModel and basicModel, and the catalog's window and output (200000 and 64000 when not known, the output cut to the window). It makes that profile active and copies it to config.json. The profile the user was on, whether there was an active-profile.json at all, and a magpie.json of their own are stashed; Disconnect, `magpie snow default`, or picking a model of one of the user's profiles puts those back. A Snow from before profiles (config.json alone) gets the default.json Snow itself would make, so its next start doesn't take magpie's config for the user's. Check reports a base URL, key or request method moved off magpie's; Sync rewrites the profile on a port move. A WSL distro's ~/.snow is found and written the same way. Registered: agents.go, the WSL kinds, SNOW_CONFIG_DIR in agentenv, the npm update spec (snow-ai), the icon (Snow App's own, trimmed to 96px), README, docs/reference.md, docs/subsystems/agent-wiring.md, the site's agent list and start.html (en, zh, ja). Run: - go test -tags nogui ./... in a scratch HOME: 44 packages ok. New: TestSnow, TestSnowOwnProfile, TestSnowNoProfiles, TestSnowUsersMagpieProfile, TestSnowCheckSync, TestAgentSnowDefault; snow added to TestOwnProviderNamedAsMagpies, the WSL more-agents tests and TestMaxTokensWithinContextWindow. All fail before this commit (no agent "snow"). - go vet -tags nogui for darwin, linux and windows; gofmt -l clean. - Real Snow CLI 0.9.2 in a sandbox HOME against a sandbox magpie (`magpie serve` on 127.0.0.1:3571, a fake OpenAI upstream on 3561): `magpie snow fake/pro` made the magpie profile active, `snow --ask` answered through the gateway (POST /v1/chat/completions, model pro, logged as agent snow), and `magpie snow default` left profiles, active-profile.json and config.json byte-identical to before. - GUI: agent-layout, agent-connect, agent-models-picker, agent-field-names and models-add-and-icons in Chromium and WebKit; a Snow CLI row checked at 520-960px in en, zh, zh-TW, ja and de in both engines. No new GUI strings.
This commit is contained in:
1 parent
43cb6fe34e
commit
9bb2a467e4
21 files changed
+891
-7
No files matched your search
@@ -81,7 +81,7 @@ magpie tui # the whole thing, in a terminal
|
||||
<table>
|
||||
<tr><td>
|
||||
|
||||
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
|
||||
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Snow CLI · Alma · Cindy
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ magpie tui # 全部功能,在终端里
|
||||
<table>
|
||||
<tr><td>
|
||||
|
||||
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
|
||||
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Snow CLI · Alma · Cindy
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -129,6 +129,7 @@ line; agents connected to magpie lose it when it quits.
|
||||
| T3 Code | `~/.t3/userdata/settings.json` (`$T3CODE_HOME/userdata`) | provider (a `magpie` provider instance on Claude Code, magpie's models as its custom models) |
|
||||
| OpenHanako | `~/.hanako/provider-catalog.json` + `agents/<id>/config.yaml` (`$HANA_HOME`; its local API while it runs) | model (the primary agent's; magpie's models as a provider) |
|
||||
| AtomCode | `~/.atomcode/config.toml` (`$ATOMCODE_HOME`) | model, effort (a `magpie` provider account, one model table per catalog model as its own sign-in writes) |
|
||||
| Snow CLI | `~/.snow/profiles/magpie.json` (`$SNOW_CONFIG_DIR`), made the active profile and copied to `config.json` | model (the user's other profiles' models too; Snow App takes the profile in with "Sync Snow CLI API config") |
|
||||
| Alma | Alma's local API (`localhost:23001`, while Alma runs; alma-server's data in `$ALMA_DATA_DIR`, `$XDG_DATA_HOME/alma` or `~/.local/share/alma` on Linux) | model (Alma's default; magpie's models as a provider), and Image Generation's model when it is Auto: the one magpie draws with |
|
||||
|
||||
Provider-scoped agents (OpenCode, MiMo Code, Pi, OmO, Aside, Goose, Crush, omp, Hermes Agent) take `provider/model`.
|
||||
|
||||
@@ -47,6 +47,7 @@ something else shows up as drift.
|
||||
- Claude Code's sign-in (`login`) is magpie's key (`""`, the default) or claude.ai (`claudeai`). claude.ai writes `ANTHROPIC_AUTH_TOKEN` empty, which Claude Code reads as no key: it keeps its claude.ai login and sends that to magpie, which never passes it on (`claudeSignIn` in the gateway). Every later write keeps the token empty (`wiredKey`), and a key put there by hand is reported by Check, not taken for magpie's. It's offered only where the gateway takes any key (`gwKey() == gateway.Token`: loopback, a WSL distro mirrored), not from a distro under NAT while magpie is shared on the network, where only the sharing key gets in. The choice is stashed, so it survives Disconnect and a port move. An empty token counts as magpie's only beside the address magpie last wrote (`claude.wired_at` in the stash, which Rewire carries to the new port, `keyOurs`): an endpoint of the user's own with an empty token stays theirs when the model goes back to Claude Code's own.
|
||||
- MiniMax Code takes a model ref with one slash in the whole (a SubAgent's `model` must match `^[^/\s]+\/[^/\s]+$`, #1387), and magpie's refs there are `custom_provider:magpie/<model>`. So a model under `custom_provider.magpie` is written with `~` for each slash of its catalog id (`bb-codex~gpt-6.1-sol`, `provider.FlatID`), only when that spelling stands for one served id (`provider.Flattened`); the gateway takes it back. A config an older magpie wrote moves over on the next sync: the user's keys on a model go to its new key, `defaultModel`, `defaultLightModel` and `memory.captureModel`/`consolidationModel` follow (`mcodeRefs`), and the slashed key stays, switched off (`enabled: false`), so a session begun on it still resumes. magpie never edits the user's own `agents/<name>/agent.md`. No other agent was found with this limit ([`minimax.go`](../../internal/agent/minimax.go)).
|
||||
- Codex's "subagent model" field writes nothing to Codex's config: its lead names each subagent's model in `spawn_agent`, which `[agents]` can't overrule. It is magpie's setting (`settings.CodexSubagentModel`, [`codex_subagent.go`](../../internal/provider/codex_subagent.go)), and the gateway moves the subagent's request onto it (see [gateway routing](gateway-routing.md)). Its options are only ChatGPT accounts' models, as a subagent's task is sealed for them. Its setter keeps a ChatGPT account's model no account serves now, so a profile or backup put back on another computer still applies (`ApplyFields` stops at the first failed Set), and refuses any other provider's model.
|
||||
- Snow CLI runs on one profile at a time (`profiles/<name>.json` in `~/.snow` or `$SNOW_CONFIG_DIR`, named in `active-profile.json`) and copies the active one over `config.json` at every start, `snow --ask` too ([`snow.go`](../../internal/agent/snow.go)). magpie writes a `magpie` profile, makes it the active one, and copies it to `config.json` itself, so a Snow already running and Snow App's "Sync Snow CLI API config" see the same. It is made from the profile the user was on, their settings kept and its OAuth sign-in left out. The profile they were on (or that there was no `active-profile.json`) and a `magpie.json` of their own are stashed and put back by Disconnect. A Snow from before profiles (`config.json` alone) gets the `default.json` Snow itself would have made of it, so its next start doesn't take magpie's for the user's. Picking a model of another of the user's profiles switches Snow to that profile.
|
||||
- A value an agent spells as its own provider's model is never taken for one of magpie's, even when magpie has a provider of the same name (`Spelled`, #835).
|
||||
- `unreachable` never sets anything: the config is right, and the page's pill only says what has to listen. The exception is an address a WSL distro reached Windows at before (`distro.Was`, kept in `wsl.json`) while it reaches Windows at another now: the pill offers that one, and `Reapply` moves the config there (`Agent.move`, Codex's `codexMoveGateway`). An address no distro was seen at is the user's own and is never moved. An unknown answer (no probe yet) and a gateway down on loopback too are not drift. Tests stub `reachProbe` in `TestMain`.
|
||||
- Listing never starts a stopped WSL distro. A stopped distro's agent shows what was last seen there (`asleep`, `wsl.json`); setting one of its fields writes its files, which starts the distro.
|
||||
|
||||
@@ -114,6 +114,7 @@ func All() []*Agent {
|
||||
t3code(home),
|
||||
hanako(home),
|
||||
atomcode(home),
|
||||
snow(home),
|
||||
alma(),
|
||||
cindy(),
|
||||
}, append(ompProfiles(home), wslAgents()...)...)
|
||||
|
||||
@@ -119,6 +119,8 @@ var cliSpecs = map[string]cliSpec{
|
||||
// exists, but its installed-binary update path is not exercised here, so
|
||||
// only its npm package and brew cask are recognized
|
||||
"atomcode": {npm: []string{"@atomgit.com/atomcode"}, brew: []string{"atomcode"}},
|
||||
// Snow CLI is published on npm alone (snow-ai)
|
||||
"snow": {npm: []string{"snow-ai"}},
|
||||
// omo update, however it was installed: OmO's own updater, which moves
|
||||
// the engine it pins (senpi) with it
|
||||
"omo": {npm: []string{"omo-ai"},
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
package agent
|
||||
|
||||
// Snow CLI (`snow`, npm snow-ai, MayDay-wpf/snow-cli) keeps its settings in
|
||||
// ~/.snow ($SNOW_CONFIG_DIR when set). Each API setup is a profile,
|
||||
// profiles/<name>.json, the whole of its config ({"snowcfg": {baseUrl,
|
||||
// baseUrlMode, apiKey, requestMethod, advancedModel, basicModel,
|
||||
// maxContextTokens, maxTokens, …}, …}); active-profile.json
|
||||
// ({"activeProfile": "<name>"}) names the one in use, "default" when it is
|
||||
// missing, and config.json is a copy of that one, which Snow writes again
|
||||
// from the profile at every start (utils/config/configManager.ts
|
||||
// initializeProfiles, switchProfile).
|
||||
//
|
||||
// magpie adds a profile of its own, profiles/magpie.json: a copy of the
|
||||
// profile the user was on — their other settings kept, a subscription
|
||||
// sign-in of theirs (snowcfg.oauth) left out — with the gateway's /v1 as
|
||||
// its base URL, a key naming Snow CLI, Chat Completions as its protocol and
|
||||
// the model picked as its main (advancedModel) and light (basicModel) model,
|
||||
// the gateway resolving "<provider>/<model>" itself. It makes that profile
|
||||
// the active one and copies it to config.json, as Snow's own profile switch
|
||||
// does; the profile the user was on is stashed and made active again when
|
||||
// magpie steps out, and magpie.json goes. Snow App
|
||||
// (MayDay-wpf/snow-app) keeps API setups of its own: its "Sync Snow CLI API
|
||||
// config" button copies these profiles, magpie's among them, into it.
|
||||
//
|
||||
// Snow's requests carry no User-Agent of its own (an x-snow: <version>
|
||||
// header), so the key is what tells them apart: gateway.TokenFor("snow").
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/jsonc"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
)
|
||||
|
||||
// snowProfile is the name of the profile magpie writes.
|
||||
const snowProfile = magpieID
|
||||
|
||||
// snowContext and snowOutput are what Snow is told of a model whose limits
|
||||
// magpie doesn't know: its own defaults.
|
||||
const (
|
||||
snowContext = 200000
|
||||
snowOutput = 64000
|
||||
)
|
||||
|
||||
func snow(home string) *Agent { return snowIn(here(home)) }
|
||||
|
||||
// snowIn is Snow CLI at a place: this machine's home, or a WSL distro's (see
|
||||
// wsl.go), whose profile names the gateway as the distro reaches it, with
|
||||
// the key it takes from there.
|
||||
func snowIn(at place) *Agent {
|
||||
dir := filepath.Join(at.home, ".snow")
|
||||
if d := strings.TrimSpace(at.getenv("SNOW_CONFIG_DIR")); filepath.IsAbs(d) {
|
||||
dir = filepath.Clean(d)
|
||||
}
|
||||
profiles := filepath.Join(dir, "profiles")
|
||||
ours := filepath.Join(profiles, snowProfile+".json")
|
||||
activeFile := filepath.Join(dir, "active-profile.json")
|
||||
legacyActive := filepath.Join(dir, "active-profile.txt")
|
||||
config := filepath.Join(dir, "config.json")
|
||||
defaultProfile := filepath.Join(profiles, "default.json")
|
||||
profileOf := func(name string) string { return filepath.Join(profiles, name+".json") }
|
||||
key := func() string { return agentKeyAt("snow", at.gw()) }
|
||||
stashKey := "snow:" + dir
|
||||
// keyActive is the profile the user was on, keyNone that there was no
|
||||
// active-profile.json to put back, keyHad the bytes of a magpie.json of
|
||||
// the user's own that magpie's took the place of
|
||||
keyActive, keyNone, keyHad := stashKey+":active", stashKey+":active.none", stashKey+":had"
|
||||
|
||||
// active is the profile Snow starts on, as it reads it
|
||||
active := func() string {
|
||||
if v, ok := edit.GetJSON(activeFile, "activeProfile"); ok {
|
||||
return v
|
||||
}
|
||||
if b, err := edit.Read(legacyActive); err == nil && strings.TrimSpace(string(b)) != "" {
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
// onOurs says Snow is on magpie's profile: the active one, and a key
|
||||
// magpie gave it, so a profile of the user's own named "magpie" is not
|
||||
// taken for it
|
||||
onOurs := func() bool {
|
||||
if active() != snowProfile {
|
||||
return false
|
||||
}
|
||||
k, ok := edit.GetJSON(ours, "snowcfg.apiKey")
|
||||
return ok && snowOurKey(k)
|
||||
}
|
||||
// model is the active profile's main model: config.json's when the
|
||||
// profile file isn't there (a Snow from before profiles)
|
||||
model := func(name string) string {
|
||||
if v, ok := edit.GetJSON(profileOf(name), "snowcfg.advancedModel"); ok {
|
||||
return v
|
||||
}
|
||||
if _, err := os.Stat(profileOf(name)); errors.Is(err, fs.ErrNotExist) {
|
||||
v, _ := edit.GetJSON(config, "snowcfg.advancedModel")
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
get := func() string {
|
||||
if onOurs() {
|
||||
if v := model(snowProfile); v != "" {
|
||||
return magpieID + "/" + v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return model(active())
|
||||
}
|
||||
|
||||
// mirror copies a profile to config.json, as Snow's profile switch does
|
||||
mirror := func(name string) error {
|
||||
b, err := edit.Read(profileOf(name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return edit.WriteAtomic(config, b)
|
||||
}
|
||||
// setActive names the profile Snow starts on
|
||||
setActive := func(name string) error {
|
||||
return edit.WriteAtomic(activeFile, []byte(`{
|
||||
"activeProfile": `+jsonString(name)+`
|
||||
}`))
|
||||
}
|
||||
|
||||
// write makes magpie.json for the model ref, from the profile the user
|
||||
// is on (base) where there is one, and points it at the gateway
|
||||
write := func(ref, base string) error {
|
||||
var raw []byte
|
||||
if b, err := edit.Read(ours); err == nil && b != nil && snowIsOurs(b) {
|
||||
raw = b
|
||||
} else if base != "" {
|
||||
if b, err := edit.Read(profileOf(base)); err == nil && b != nil {
|
||||
raw = b
|
||||
} else if b, err := edit.Read(config); err == nil && b != nil {
|
||||
raw = b
|
||||
}
|
||||
}
|
||||
out, err := snowProfileFor(raw, at.v1(), key(), ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return edit.WriteAtomic(ours, out)
|
||||
}
|
||||
|
||||
// leave puts Snow back on the profile the user had (to, or the stashed
|
||||
// one), takes magpie.json away (or puts back the user's own of that
|
||||
// name), and copies the profile it is on to config.json
|
||||
leave := func(to string) error {
|
||||
was, none := unstash(keyActive), stashLoad()[keyNone] != ""
|
||||
forget(keyNone)
|
||||
// a profile asked for by name is made the active one; else the one
|
||||
// the user was on, or none at all when Snow had no active file
|
||||
if to == "" && !none {
|
||||
to = was
|
||||
}
|
||||
switch {
|
||||
case to != "":
|
||||
if err := setActive(to); err != nil {
|
||||
return err
|
||||
}
|
||||
case none:
|
||||
if err := os.Remove(activeFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if err := setActive("default"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if had := unstash(keyHad); had != "" {
|
||||
if err := edit.WriteAtomic(ours, []byte(had)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if b, err := edit.Read(ours); err == nil && b != nil && snowIsOurs(b) {
|
||||
if err := os.Remove(ours); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := active()
|
||||
if _, err := os.Stat(profileOf(now)); err == nil {
|
||||
return mirror(now)
|
||||
}
|
||||
// no profile to copy: a config.json still magpie's goes, or Snow
|
||||
// would make it the user's default profile at its next start
|
||||
if b, err := edit.Read(config); err == nil && b != nil && snowIsOurs(b) {
|
||||
if err := os.Remove(config); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return atomic(&Agent{
|
||||
ID: "snow", Name: "Snow CLI", Icon: "snow", Aliases: []string{"snow-cli", "snow-ai", "snow-app"}, Spelled: prefixed,
|
||||
Bin: "snow", Dir: dir, Path: ours,
|
||||
Sync: func() error {
|
||||
b, err := edit.Read(ours)
|
||||
if err != nil || b == nil || !snowIsOurs(b) {
|
||||
return err
|
||||
}
|
||||
ref := gjson.GetBytes(jsonc.ToJSONInPlace(b), "snowcfg.advancedModel").String()
|
||||
if err := write(ref, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if active() == snowProfile {
|
||||
return mirror(snowProfile)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Notice: func() string {
|
||||
if Running(`(^|/)snow( |$)`) {
|
||||
return "Snow CLI reads its profile at start-up — restart open snow sessions to use this."
|
||||
}
|
||||
return ""
|
||||
},
|
||||
Check: func() string {
|
||||
if active() != snowProfile {
|
||||
return ""
|
||||
}
|
||||
b, _ := edit.Read(ours)
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
cfg := gjson.GetBytes(jsonc.ToJSONInPlace(b), "snowcfg")
|
||||
if !snowOurKey(cfg.Get("apiKey").String()) {
|
||||
return ""
|
||||
}
|
||||
if m := cfg.Get("requestMethod").String(); m != "" && m != "chat" {
|
||||
return "Snow CLI's requestMethod (profiles/" + snowProfile + ".json) is " + m + ", not chat, so magpie's gateway won't take its requests"
|
||||
}
|
||||
return wiringOff("Snow CLI", ours, func(k string) (string, bool) {
|
||||
g := cfg.Get(k)
|
||||
return g.String(), g.Exists()
|
||||
}, "baseUrl", at.v1(), "apiKey", key())
|
||||
},
|
||||
Fields: []Field{{
|
||||
Key: "model", Label: "model",
|
||||
Get: get,
|
||||
Set: func(v string) error {
|
||||
if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) {
|
||||
base := active()
|
||||
if !onOurs() {
|
||||
if b, err := edit.Read(ours); err == nil && b != nil && !snowIsOurs(b) {
|
||||
stash(map[string]string{keyHad: string(b)})
|
||||
}
|
||||
if _, err := os.Stat(activeFile); errors.Is(err, fs.ErrNotExist) {
|
||||
if _, err := os.Stat(legacyActive); errors.Is(err, fs.ErrNotExist) {
|
||||
stash(map[string]string{keyNone: "1"})
|
||||
}
|
||||
}
|
||||
stash(map[string]string{keyActive: base})
|
||||
// a Snow from before profiles copies config.json
|
||||
// into a new default profile at its next start and
|
||||
// makes that the active one: done here, so it is
|
||||
// the user's config that is copied, not magpie's
|
||||
if _, err := os.Stat(defaultProfile); errors.Is(err, fs.ErrNotExist) {
|
||||
if b, err := edit.Read(config); err == nil && b != nil && !snowIsOurs(b) {
|
||||
if err := edit.WriteAtomic(defaultProfile, b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
base = ""
|
||||
}
|
||||
if err := write(ref, base); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setActive(snowProfile); err != nil {
|
||||
return err
|
||||
}
|
||||
return mirror(snowProfile)
|
||||
}
|
||||
if !onOurs() {
|
||||
// on a profile of the user's own: its model is theirs
|
||||
// to change, as Snow's own model picker does
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
name := active()
|
||||
if p := snowFindProfile(profiles, v, name); p != "" && p != name {
|
||||
if err := setActive(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return mirror(p)
|
||||
}
|
||||
if _, err := os.Stat(profileOf(name)); err != nil {
|
||||
return edit.SetJSON(config, edit.KV{Path: "snowcfg.advancedModel", Value: v})
|
||||
}
|
||||
if err := edit.SetJSON(profileOf(name), edit.KV{Path: "snowcfg.advancedModel", Value: v}); err != nil {
|
||||
return err
|
||||
}
|
||||
return mirror(name)
|
||||
}
|
||||
// leaving magpie's profile: for the user's profile with
|
||||
// that model, or the one they were on
|
||||
to := ""
|
||||
if v != "" {
|
||||
was := stashLoad()[keyActive]
|
||||
to = snowFindProfile(profiles, v, was)
|
||||
if to == "" {
|
||||
to = was
|
||||
if to == "" {
|
||||
to = "default"
|
||||
}
|
||||
if _, err := os.Stat(profileOf(to)); err == nil {
|
||||
if err := edit.SetJSON(profileOf(to), edit.KV{Path: "snowcfg.advancedModel", Value: v}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return leave(to)
|
||||
},
|
||||
Options: func(cur map[string]string) []Option {
|
||||
return append(snowOwnOptions(profiles, cur["model"]), viaMagpie("snow", magpieID+"/")...)
|
||||
},
|
||||
}},
|
||||
}, ours, activeFile, config, defaultProfile)
|
||||
}
|
||||
|
||||
// snowOurKey says k is a key magpie gives Snow CLI.
|
||||
func snowOurKey(k string) bool {
|
||||
return k != "" && (ourKey(k) || strings.HasPrefix(k, gateway.Token+"-"))
|
||||
}
|
||||
|
||||
// snowIsOurs says a profile (or config.json) is magpie's: its key is one
|
||||
// magpie gave.
|
||||
func snowIsOurs(b []byte) bool {
|
||||
return snowOurKey(gjson.GetBytes(jsonc.ToJSONInPlace(append([]byte(nil), b...)), "snowcfg.apiKey").String())
|
||||
}
|
||||
|
||||
// snowProfileFor is the profile magpie writes for ref: from (a profile of
|
||||
// the user's, or magpie's own already) with the gateway set in, the user's
|
||||
// sign-in left out; Snow's own defaults where from is empty.
|
||||
func snowProfileFor(from []byte, v1, key, ref string) ([]byte, error) {
|
||||
cfg := map[string]any{}
|
||||
if len(from) > 0 {
|
||||
if err := json.Unmarshal(jsonc.ToJSON(from), &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sc, _ := cfg["snowcfg"].(map[string]any)
|
||||
if sc == nil {
|
||||
sc = map[string]any{}
|
||||
}
|
||||
delete(sc, "oauth")
|
||||
ctx, out := snowContext, snowOutput
|
||||
for _, m := range magpieModels("snow") {
|
||||
if m.ID == ref {
|
||||
if m.Context > 0 {
|
||||
ctx = m.Context
|
||||
}
|
||||
if n := maxTokens(m); n > 0 {
|
||||
out = n
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// an output above the window Snow is told is cut to it, as for a
|
||||
// model whose window isn't known
|
||||
out = min(out, ctx)
|
||||
sc["baseUrl"] = v1
|
||||
sc["baseUrlMode"] = "base"
|
||||
sc["apiKey"] = key
|
||||
sc["requestMethod"] = "chat"
|
||||
sc["advancedModel"] = ref
|
||||
sc["basicModel"] = ref
|
||||
sc["maxContextTokens"] = ctx
|
||||
sc["maxTokens"] = out
|
||||
cfg["snowcfg"] = sc
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// snowProfiles are the profiles of the user's own: their names, and the
|
||||
// main model each is on.
|
||||
func snowProfiles(dir string) (names []string, models map[string]string) {
|
||||
models = map[string]string{}
|
||||
es, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, models
|
||||
}
|
||||
for _, e := range es {
|
||||
name, ok := strings.CutSuffix(e.Name(), ".json")
|
||||
if !ok || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := edit.Read(filepath.Join(dir, e.Name()))
|
||||
if err != nil || b == nil || snowIsOurs(b) {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
models[name] = gjson.GetBytes(jsonc.ToJSONInPlace(b), "snowcfg.advancedModel").String()
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, models
|
||||
}
|
||||
|
||||
// snowFindProfile is the user's profile on model v: prefer, when it is on
|
||||
// it, else the first by name; "" for none.
|
||||
func snowFindProfile(dir, v, prefer string) string {
|
||||
names, models := snowProfiles(dir)
|
||||
if prefer != "" && models[prefer] == v {
|
||||
return prefer
|
||||
}
|
||||
for _, n := range names {
|
||||
if models[n] == v {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// snowOwnOptions are the models of the user's own profiles, and the
|
||||
// current pick.
|
||||
func snowOwnOptions(dir, cur string) []Option {
|
||||
names, models := snowProfiles(dir)
|
||||
seen := map[string]bool{}
|
||||
var out []Option
|
||||
for _, n := range names {
|
||||
m := models[n]
|
||||
if m == "" || seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
out = append(out, Option{Value: m, Icon: modelIcon("", m)})
|
||||
}
|
||||
if cur != "" && !seen[cur] && !strings.HasPrefix(cur, magpieID+"/") {
|
||||
out = append([]Option{{Value: cur, Icon: modelIcon("", cur)}}, out...)
|
||||
}
|
||||
return group("Snow CLI", out)
|
||||
}
|
||||
|
||||
func jsonString(s string) string { b, _ := json.Marshal(s); return string(b) }
|
||||
@@ -0,0 +1,342 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// snowDefault is a profile as Snow CLI 0.9.2 writes it for someone on a
|
||||
// provider of their own, with settings magpie has nothing to do with.
|
||||
const snowDefault = `{
|
||||
"snowcfg": {
|
||||
"baseUrl": "https://idealab.example.com/v1",
|
||||
"baseUrlMode": "auto",
|
||||
"apiKey": "team-key",
|
||||
"requestMethod": "chat",
|
||||
"advancedModel": "glm-5",
|
||||
"basicModel": "glm-5-air",
|
||||
"supportsVision": true,
|
||||
"maxContextTokens": 200000,
|
||||
"maxTokens": 64000,
|
||||
"streamingDisplay": false,
|
||||
"systemPromptId": "terse"
|
||||
},
|
||||
"companionMuted": true
|
||||
}`
|
||||
|
||||
// snowWork is a second profile of the user's, signed in to a subscription
|
||||
// through Snow's own OAuth.
|
||||
const snowWork = `{
|
||||
"snowcfg": {
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"apiKey": "",
|
||||
"requestMethod": "responses",
|
||||
"advancedModel": "kimi-k3",
|
||||
"basicModel": "kimi-k3",
|
||||
"oauth": {"provider": "example", "accessToken": "secret"}
|
||||
}
|
||||
}`
|
||||
|
||||
func snowHome(t *testing.T) (home, dir string) {
|
||||
t.Helper()
|
||||
home = syncHome(t)
|
||||
if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"pro"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir = filepath.Join(home, ".snow")
|
||||
os.MkdirAll(filepath.Join(dir, "profiles"), 0o755)
|
||||
return home, dir
|
||||
}
|
||||
|
||||
// snowUser lays out ~/.snow as Snow leaves it with the user's two
|
||||
// profiles, default the active one.
|
||||
func snowUser(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
os.WriteFile(filepath.Join(dir, "profiles", "default.json"), []byte(snowDefault), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "profiles", "work.json"), []byte(snowWork), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "active-profile.json"), []byte("{\n \"activeProfile\": \"default\"\n}"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), []byte(snowDefault), 0o644)
|
||||
}
|
||||
|
||||
func snowCfg(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
var f map[string]any
|
||||
if err := json.Unmarshal([]byte(readFile(path)), &f); err != nil {
|
||||
t.Fatalf("%s: %v\n%s", path, err, readFile(path))
|
||||
}
|
||||
sc, _ := f["snowcfg"].(map[string]any)
|
||||
if sc == nil {
|
||||
t.Fatalf("%s has no snowcfg:\n%s", path, readFile(path))
|
||||
}
|
||||
return sc
|
||||
}
|
||||
|
||||
func snowActive(dir string) string {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "active-profile.json"))
|
||||
if err != nil {
|
||||
return "(none)"
|
||||
}
|
||||
var v struct{ ActiveProfile string }
|
||||
json.Unmarshal(b, &v)
|
||||
return v.ActiveProfile
|
||||
}
|
||||
|
||||
func TestSnow(t *testing.T) {
|
||||
_, dir := snowHome(t)
|
||||
snowUser(t, dir)
|
||||
a, err := Find("snow")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !a.Detected() || a.Dir != dir {
|
||||
t.Fatalf("not found in %s: %+v", dir, a)
|
||||
}
|
||||
if got := a.Values()["model"]; got != "glm-5" {
|
||||
t.Fatalf("model: %q", got)
|
||||
}
|
||||
// the picker: the user's profiles' models, then magpie's catalog
|
||||
opts := a.Field("model").Options(a.Values())
|
||||
if len(opts) < 3 || opts[0].Value != "glm-5" || opts[1].Value != "kimi-k3" || opts[0].Group != "Snow CLI" {
|
||||
t.Fatalf("own options: %+v", opts)
|
||||
}
|
||||
var via bool
|
||||
for _, o := range opts {
|
||||
via = via || o.Value == "magpie/deepseek/pro"
|
||||
}
|
||||
if !via {
|
||||
t.Fatalf("no magpie/deepseek/pro in %+v", opts)
|
||||
}
|
||||
|
||||
if err := a.Apply("model", "magpie/deepseek/pro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ours := filepath.Join(dir, "profiles", "magpie.json")
|
||||
sc := snowCfg(t, ours)
|
||||
for k, want := range map[string]any{
|
||||
"baseUrl": gatewayV1(), "baseUrlMode": "base", "apiKey": gateway.TokenFor("snow"), "requestMethod": "chat",
|
||||
"advancedModel": "deepseek/pro", "basicModel": "deepseek/pro",
|
||||
// the user's own settings come along
|
||||
"streamingDisplay": false, "systemPromptId": "terse",
|
||||
} {
|
||||
if sc[k] != want {
|
||||
t.Errorf("snowcfg.%s = %v, want %v", k, sc[k], want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(readFile(ours), `"companionMuted": true`) {
|
||||
t.Errorf("the profile's other settings are gone:\n%s", readFile(ours))
|
||||
}
|
||||
if got := snowActive(dir); got != "magpie" {
|
||||
t.Fatalf("active profile %q", got)
|
||||
}
|
||||
// config.json is a copy of the active profile, as Snow's switch makes it
|
||||
if readFile(filepath.Join(dir, "config.json")) != readFile(ours) {
|
||||
t.Fatalf("config.json isn't magpie's profile:\n%s", readFile(filepath.Join(dir, "config.json")))
|
||||
}
|
||||
if readFile(filepath.Join(dir, "profiles", "default.json")) != snowDefault || readFile(filepath.Join(dir, "profiles", "work.json")) != snowWork {
|
||||
t.Fatal("a profile of the user's was rewritten")
|
||||
}
|
||||
if got := a.Values()["model"]; got != "magpie/deepseek/pro" {
|
||||
t.Fatalf("model after pick: %q", got)
|
||||
}
|
||||
if !a.Wired() {
|
||||
t.Fatal("not connected on magpie's model")
|
||||
}
|
||||
if msg := a.Check(); msg != "" {
|
||||
t.Fatalf("check: %s", msg)
|
||||
}
|
||||
// its own profile's list doesn't take magpie's for the user's
|
||||
for _, o := range a.Field("model").Options(a.Values()) {
|
||||
if o.Group == "Snow CLI" && o.Value == "deepseek/pro" {
|
||||
t.Fatalf("magpie's profile listed as the user's: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// another of magpie's: the same profile, the model moved
|
||||
if err := a.Apply("model", "magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc := snowCfg(t, ours); sc["advancedModel"] != "relay/glm-4.6" || sc["streamingDisplay"] != false {
|
||||
t.Fatalf("second pick: %v", sc)
|
||||
}
|
||||
// glm-4.6's context, from the catalog
|
||||
if sc := snowCfg(t, ours); sc["maxContextTokens"] != float64(204800) {
|
||||
t.Fatalf("context: %v", sc["maxContextTokens"])
|
||||
}
|
||||
|
||||
// back: the profile the user was on, magpie's gone
|
||||
if err := a.Apply("model", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := snowActive(dir); got != "default" {
|
||||
t.Fatalf("active after reset: %q", got)
|
||||
}
|
||||
if _, err := os.Stat(ours); !os.IsNotExist(err) {
|
||||
t.Fatalf("magpie.json left: %v", err)
|
||||
}
|
||||
if readFile(filepath.Join(dir, "config.json")) != snowDefault {
|
||||
t.Fatalf("config.json not the user's profile again:\n%s", readFile(filepath.Join(dir, "config.json")))
|
||||
}
|
||||
if got := a.Values()["model"]; got != "glm-5" {
|
||||
t.Fatalf("model after reset: %q", got)
|
||||
}
|
||||
for k := range stashLoad() {
|
||||
if strings.HasPrefix(k, "snow:") {
|
||||
t.Errorf("stash keeps %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A model of another profile of the user's, picked while on magpie's,
|
||||
// switches Snow to that profile; its OAuth sign-in never comes into
|
||||
// magpie's.
|
||||
func TestSnowOwnProfile(t *testing.T) {
|
||||
_, dir := snowHome(t)
|
||||
snowUser(t, dir)
|
||||
os.WriteFile(filepath.Join(dir, "active-profile.json"), []byte(`{"activeProfile": "work"}`), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), []byte(snowWork), 0o644)
|
||||
a, _ := Find("snow")
|
||||
if err := a.Apply("model", "magpie/deepseek/pro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ours := filepath.Join(dir, "profiles", "magpie.json")
|
||||
if body := readFile(ours); strings.Contains(body, "oauth") || strings.Contains(body, "secret") {
|
||||
t.Fatalf("the user's sign-in copied into magpie's profile:\n%s", body)
|
||||
}
|
||||
if sc := snowCfg(t, ours); sc["requestMethod"] != "chat" {
|
||||
t.Fatalf("requestMethod %v", sc["requestMethod"])
|
||||
}
|
||||
if err := a.Apply("model", "glm-5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := snowActive(dir); got != "default" {
|
||||
t.Fatalf("active %q, want default (the profile on glm-5)", got)
|
||||
}
|
||||
if _, err := os.Stat(ours); !os.IsNotExist(err) {
|
||||
t.Fatal("magpie.json left")
|
||||
}
|
||||
if readFile(filepath.Join(dir, "config.json")) != snowDefault {
|
||||
t.Fatal("config.json isn't default's")
|
||||
}
|
||||
if readFile(filepath.Join(dir, "profiles", "work.json")) != snowWork {
|
||||
t.Fatal("work.json rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
// A Snow from before profiles (config.json alone) and one never started
|
||||
// (an empty ~/.snow) are each left as they were.
|
||||
func TestSnowNoProfiles(t *testing.T) {
|
||||
t.Run("config.json alone", func(t *testing.T) {
|
||||
_, dir := snowHome(t)
|
||||
os.Remove(filepath.Join(dir, "profiles"))
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), []byte(snowDefault), 0o644)
|
||||
a, _ := Find("snow")
|
||||
if got := a.Values()["model"]; got != "glm-5" {
|
||||
t.Fatalf("model: %q", got)
|
||||
}
|
||||
if err := a.Apply("model", "magpie/deepseek/pro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Snow makes a default profile of config.json at its next start
|
||||
// when there is none: the user's, not magpie's
|
||||
if readFile(filepath.Join(dir, "profiles", "default.json")) != snowDefault {
|
||||
t.Fatalf("default.json:\n%s", readFile(filepath.Join(dir, "profiles", "default.json")))
|
||||
}
|
||||
if err := a.Apply("model", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "active-profile.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("active-profile.json left, there was none")
|
||||
}
|
||||
if readFile(filepath.Join(dir, "config.json")) != snowDefault {
|
||||
t.Fatal("config.json not the user's")
|
||||
}
|
||||
if got := a.Values()["model"]; got != "glm-5" {
|
||||
t.Fatalf("model after: %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("never started", func(t *testing.T) {
|
||||
_, dir := snowHome(t)
|
||||
a, _ := Find("snow")
|
||||
if err := a.Apply("model", "magpie/deepseek/pro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sc := snowCfg(t, filepath.Join(dir, "profiles", "magpie.json"))
|
||||
if sc["baseUrl"] != gatewayV1() || sc["maxContextTokens"] != float64(snowContext) {
|
||||
t.Fatalf("%v", sc)
|
||||
}
|
||||
if err := a.Apply("model", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"active-profile.json", "config.json", "profiles/magpie.json", "profiles/default.json"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s left behind", f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A profile of the user's own named magpie is theirs: not read as
|
||||
// magpie's, and back as it was once magpie steps out.
|
||||
func TestSnowUsersMagpieProfile(t *testing.T) {
|
||||
_, dir := snowHome(t)
|
||||
snowUser(t, dir)
|
||||
mine := filepath.Join(dir, "profiles", "magpie.json")
|
||||
os.WriteFile(mine, []byte(snowWork), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "active-profile.json"), []byte(`{"activeProfile": "magpie"}`), 0o644)
|
||||
a, _ := Find("snow")
|
||||
if got := a.Values()["model"]; got != "kimi-k3" || a.Wired() {
|
||||
t.Fatalf("the user's own magpie profile reads %q, wired %v", got, a.Wired())
|
||||
}
|
||||
if err := a.Apply("model", "magpie/deepseek/pro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc := snowCfg(t, mine); sc["apiKey"] != gateway.TokenFor("snow") {
|
||||
t.Fatalf("%v", sc)
|
||||
}
|
||||
if err := a.Apply("model", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if readFile(mine) != snowWork || snowActive(dir) != "magpie" {
|
||||
t.Fatalf("the user's magpie profile not back (active %q):\n%s", snowActive(dir), readFile(mine))
|
||||
}
|
||||
}
|
||||
|
||||
// Something else rewrote magpie's profile: Check says what, and Sync puts
|
||||
// the gateway back; $SNOW_CONFIG_DIR is where Snow looks.
|
||||
func TestSnowCheckSync(t *testing.T) {
|
||||
home, _ := snowHome(t)
|
||||
dir := filepath.Join(home, "elsewhere")
|
||||
t.Setenv("SNOW_CONFIG_DIR", dir)
|
||||
os.MkdirAll(filepath.Join(dir, "profiles"), 0o755)
|
||||
snowUser(t, dir)
|
||||
a, _ := Find("snow")
|
||||
if a.Dir != dir {
|
||||
t.Fatalf("dir %s", a.Dir)
|
||||
}
|
||||
if err := a.Apply("model", "magpie/deepseek/pro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ours := filepath.Join(dir, "profiles", "magpie.json")
|
||||
os.WriteFile(ours, []byte(strings.Replace(readFile(ours), gatewayV1(), "http://127.0.0.1:9/v1", 1)), 0o644)
|
||||
if msg := a.Check(); !strings.Contains(msg, "baseUrl") {
|
||||
t.Fatalf("check: %q", msg)
|
||||
}
|
||||
if err := a.Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := a.Check(); msg != "" {
|
||||
t.Fatalf("after sync: %s", msg)
|
||||
}
|
||||
if readFile(filepath.Join(dir, "config.json")) != readFile(ours) {
|
||||
t.Fatal("config.json not synced")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".snow", "profiles", "magpie.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("wrote ~/.snow with SNOW_CONFIG_DIR set")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestOwnProviderNamedAsMagpies(t *testing.T) {
|
||||
ids := []string{"agy", "opencode", "openchamber", "mimocode", "pi", "aside", "omo", "goose", "zed", "vscode",
|
||||
"crush", "dsh", "commandcode", "fx", "omp", "hermes", "morph", "kimi", "empryo", "minimax-code", "droid",
|
||||
"qoder", "qoder-cn", "grok", "atomcode", "cline"}
|
||||
"qoder", "qoder-cn", "grok", "atomcode", "cline", "snow"}
|
||||
for _, id := range ids {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
if id == "aside" {
|
||||
|
||||
@@ -133,8 +133,8 @@ func TestCrushModelsCarryMaxTokens(t *testing.T) {
|
||||
// magpie hands an output limit; one whose window isn't known keeps its
|
||||
// output. ZCode and WorkBuddy cap it at zcodeMaxOutput besides, Crush falls
|
||||
// back to 16384 without a known output, OpenCode is handed no limit without
|
||||
// a window, and AtomCode, told a 128000 window when the model's isn't known,
|
||||
// is cut to that.
|
||||
// a window, and AtomCode and Snow CLI, each told a window of its own when
|
||||
// the model's isn't known, are cut to that.
|
||||
func TestMaxTokensWithinContextWindow(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
check := func(limit string, want int) {
|
||||
@@ -156,6 +156,10 @@ func TestMaxTokensWithinContextWindow(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
atomcodeCfg, _ := os.ReadFile(atomcode)
|
||||
snowOut, err := snowProfileFor(nil, gatewayV1(), "k", "relay/glm-4.6")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, wb := filepath.Join(t.TempDir(), "provider_config.json"), filepath.Join(t.TempDir(), "models.json")
|
||||
if err := zcodeRules(rules, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -170,6 +174,10 @@ func TestMaxTokensWithinContextWindow(t *testing.T) {
|
||||
if !strings.Contains(limit, "context") {
|
||||
told = strconv.Itoa(min(want, atomcodeContext))
|
||||
}
|
||||
snowTold := n
|
||||
if !strings.Contains(limit, "context") {
|
||||
snowTold = strconv.Itoa(min(want, snowContext))
|
||||
}
|
||||
wants := map[string][2]string{
|
||||
"pi": {string(pi), `"maxTokens":` + n},
|
||||
"cline": {string(cline), `"maxTokens":` + n},
|
||||
@@ -183,6 +191,7 @@ func TestMaxTokensWithinContextWindow(t *testing.T) {
|
||||
"zcode rules": {string(zcRules), `"max":` + capped},
|
||||
"workbuddy": {string(wbModels), `"maxOutputTokens": ` + capped},
|
||||
"atomcode": {string(atomcodeCfg), "max_tokens = " + told},
|
||||
"snow": {string(snowOut), `"maxTokens": ` + snowTold},
|
||||
}
|
||||
if strings.Contains(limit, "context") {
|
||||
wants["opencode"] = [2]string{string(opencode), `"output":` + n}
|
||||
|
||||
@@ -440,6 +440,11 @@ var wslKinds = []wslKind{
|
||||
return append(atomcodeOwnModels("", cur["model"]), viaMagpie("atomcode", magpieID+"/")...)
|
||||
}
|
||||
}},
|
||||
// Snow CLI's ~/.snow, its profiles the distro's own; a stopped
|
||||
// distro's are looked at once it is started
|
||||
{id: "snow", name: "Snow CLI", dir: ".snow", bin: "snow", in: snowIn,
|
||||
restart: "reads its profile at start-up — restart open snow sessions to use this.",
|
||||
asleep: wslOwnAsleep("snow", "model")},
|
||||
// no dir: Antigravity keeps its folders in ~/.gemini too, so only the
|
||||
// command says Gemini CLI is there
|
||||
{id: "gemini", name: "Gemini CLI", bin: "gemini", in: geminiIn,
|
||||
|
||||
@@ -39,6 +39,7 @@ var wslMore = []struct {
|
||||
{"codebuddy", ".codebuddy/models.json", "/v1/chat/completions"},
|
||||
{"cline", ".cline/data/settings/providers.json", "/v1"},
|
||||
{"atomcode", ".atomcode/config.toml", "/v1"},
|
||||
{"snow", ".snow/profiles/magpie.json", "/v1"},
|
||||
{"goose", ".config/goose/custom_providers/magpie.json", "/v1"},
|
||||
{"reasonix", ".reasonix/config.toml", "/v1"},
|
||||
}
|
||||
@@ -67,7 +68,7 @@ func moveHostDirs(t *testing.T) string {
|
||||
moved := t.TempDir()
|
||||
for _, v := range []string{"OPENCODE_CONFIG_DIR", "MIMOCODE_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR", "PI_CODING_AGENT_DIR",
|
||||
"HERMES_HOME", "GROK_HOME", "FACTORY_HOME_OVERRIDE", "MINIMAX_DATA_DIR", "QODER_CONFIG_DIR", "QODERCN_CONFIG_DIR", "DSH_HOME", "MISTER_MORPH_CONFIG",
|
||||
"CODEBUDDY_CONFIG_DIR", "CLINE_DIR", "CLINE_DATA_DIR", "ATOMCODE_HOME", "REASONIX_HOME", "APPDATA"} {
|
||||
"CODEBUDDY_CONFIG_DIR", "CLINE_DIR", "CLINE_DATA_DIR", "ATOMCODE_HOME", "REASONIX_HOME", "SNOW_CONFIG_DIR", "APPDATA"} {
|
||||
t.Setenv(v, filepath.Join(moved, v))
|
||||
}
|
||||
return moved
|
||||
|
||||
@@ -68,6 +68,8 @@ var Vars = []string{
|
||||
"T3CODE_HOME",
|
||||
// AtomCode's config folder
|
||||
"ATOMCODE_HOME",
|
||||
// Snow CLI's config folder, ~/.snow without it
|
||||
"SNOW_CONFIG_DIR",
|
||||
// Cursor's CLI: its config folder (its chats) and its data folder
|
||||
"CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR",
|
||||
// OpenCode and OpenChamber
|
||||
|
||||
@@ -166,7 +166,7 @@ function keepIcons(...roots) {
|
||||
keptIcons.get(e.dataset.icon).push(e);
|
||||
}
|
||||
}
|
||||
const pngIcons = new Set(["crush", "zcode", "alma", "hanako", "cindy", "typesafe", "atomcode", "mlx-serve"]);
|
||||
const pngIcons = new Set(["crush", "zcode", "alma", "hanako", "cindy", "typesafe", "atomcode", "snow", "mlx-serve"]);
|
||||
|
||||
function icon(name) {
|
||||
const kept = keptIcons?.get(name || "")?.shift();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -147,6 +147,7 @@
|
||||
<tr><td>Codex</td><td><code>~/.codex/config.toml</code>:<code>[model_providers.magpie]</code> テーブルとモデル、一覧は <code>magpie-models.json</code>。ChatGPT のサインインには手を加えません</td></tr>
|
||||
<tr><td>OpenCode、MiMo Code、Pi、Crush</td><td><code>magpie</code> プロバイダのエントリと <code>magpie/provider/model</code></td></tr>
|
||||
<tr><td>AtomCode</td><td><code>~/.atomcode/config.toml</code>:<code>magpie</code> プロバイダアカウントと、カタログ内の各モデルのエントリ</td></tr>
|
||||
<tr><td>Snow CLI</td><td><code>~/.snow/profiles/magpie.json</code>:<code>magpie</code> プロファイルを作り、使用中のプロファイルにする(Snow App は「Sync Snow CLI API config」で取り込む)</td></tr>
|
||||
<tr><td>Gemini CLI</td><td>ゲートウェイを指す <code>GOOGLE_GEMINI_BASE_URL</code>、API キー認証、そしてモデル</td></tr>
|
||||
</table></div>
|
||||
<p><b>実行中のセッションは、開始時のモデルを使い続けます。</b> エージェントは起動時に設定を読むので、変更は次のセッションから反映されます。Codex は起動時にモデル一覧も作るため、切り替えたら Codex アプリと開いている <code>codex</code> セッションを再起動してください。</p>
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
<tr><td>Codex</td><td><code>~/.codex/config.toml</code>: a <code>[model_providers.magpie]</code> table and the model, with the list in <code>magpie-models.json</code>. Your ChatGPT sign-in is left alone</td></tr>
|
||||
<tr><td>OpenCode, MiMo Code, Pi, Crush</td><td>A <code>magpie</code> provider entry, and <code>magpie/provider/model</code></td></tr>
|
||||
<tr><td>AtomCode</td><td><code>~/.atomcode/config.toml</code>: a <code>magpie</code> provider account and one model entry per catalog model</td></tr>
|
||||
<tr><td>Snow CLI</td><td><code>~/.snow/profiles/magpie.json</code>: a <code>magpie</code> profile, made the active one (Snow App takes it in with "Sync Snow CLI API config")</td></tr>
|
||||
<tr><td>Gemini CLI</td><td><code>GOOGLE_GEMINI_BASE_URL</code> pointed at the gateway, API-key auth, and the model</td></tr>
|
||||
</table></div>
|
||||
<p><b>A running session keeps the model it started with.</b> Agents read their config when they start, so the change applies to the next session. Codex builds its model list at start-up too: restart the Codex app and open <code>codex</code> sessions after a switch.</p>
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
<tr><td>Codex</td><td><code>~/.codex/config.toml</code>:一个 <code>[model_providers.magpie]</code> 表和模型,模型列表写在 <code>magpie-models.json</code>。你的 ChatGPT 登录不受影响</td></tr>
|
||||
<tr><td>OpenCode、MiMo Code、Pi、Crush</td><td>一个 <code>magpie</code> 供应商条目,以及 <code>magpie/provider/model</code></td></tr>
|
||||
<tr><td>AtomCode</td><td><code>~/.atomcode/config.toml</code>:一个 <code>magpie</code> 供应商账号,以及目录中每个模型各自的条目</td></tr>
|
||||
<tr><td>Snow CLI</td><td><code>~/.snow/profiles/magpie.json</code>:一个 <code>magpie</code> 配置文件,并设为当前配置(Snow App 用“同步 Snow CLI API 配置”导入)</td></tr>
|
||||
<tr><td>Gemini CLI</td><td><code>GOOGLE_GEMINI_BASE_URL</code> 指向网关、API 密钥认证,以及模型</td></tr>
|
||||
</table></div>
|
||||
<p><b>已在运行的会话保持它启动时的模型。</b>Agent 启动时读取配置,所以改动从下一个会话生效。Codex 也在启动时生成模型列表:切换后请重启 Codex 应用和已打开的 <code>codex</code> 会话。</p>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -478,6 +478,7 @@ footer a:hover { color: var(--ink); }
|
||||
<li><img src="/icons/zcode.png" alt="">ZCode</li>
|
||||
<li><img src="/icons/hanako.png" alt="">OpenHanako</li>
|
||||
<li><img src="/icons/atomcode.png" alt="">AtomCode</li>
|
||||
<li><img src="/icons/snow.png" alt="">Snow CLI</li>
|
||||
<li><img src="/icons/alma.png" alt="">Alma</li>
|
||||
</ul>
|
||||
<p style="margin-top:34px" data-i18n="agents.models">Every model</p>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `magpie snow a/m` puts Snow CLI on a magpie profile and makes it the
|
||||
// active one; `magpie snow default` puts back the profile the user was on,
|
||||
// its files as they were.
|
||||
func TestAgentSnowDefault(t *testing.T) {
|
||||
groupsHome(t)
|
||||
t.Setenv("SNOW_CONFIG_DIR", "")
|
||||
dir := filepath.Join(os.Getenv("HOME"), ".snow")
|
||||
os.MkdirAll(filepath.Join(dir, "profiles"), 0o755)
|
||||
own := `{
|
||||
"snowcfg": {
|
||||
"baseUrl": "https://idealab.example.com/v1",
|
||||
"apiKey": "team-key",
|
||||
"requestMethod": "chat",
|
||||
"advancedModel": "glm-5",
|
||||
"basicModel": "glm-5"
|
||||
}
|
||||
}`
|
||||
active := "{\n \"activeProfile\": \"default\"\n}"
|
||||
files := map[string]string{
|
||||
"profiles/default.json": own,
|
||||
"config.json": own,
|
||||
"active-profile.json": active,
|
||||
}
|
||||
for f, s := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read := func(f string) string {
|
||||
b, _ := os.ReadFile(filepath.Join(dir, f))
|
||||
return string(b)
|
||||
}
|
||||
if err := run([]string{"snow", "a/m"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := read("profiles/magpie.json")
|
||||
for _, want := range []string{`"advancedModel": "a/m"`, `"baseUrlMode": "base"`, `"requestMethod": "chat"`, `/v1"`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("not wired (%s missing):\n%s", want, s)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(read("active-profile.json"), `"magpie"`) || read("config.json") != s {
|
||||
t.Fatalf("magpie's profile isn't the active one:\n%s\n%s", read("active-profile.json"), read("config.json"))
|
||||
}
|
||||
if err := run([]string{"snow", "default"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for f, want := range files {
|
||||
if got := read(f); got != want {
|
||||
t.Errorf("%s not put back:\n%s", f, got)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "profiles", "magpie.json")); !os.IsNotExist(err) {
|
||||
t.Error("magpie.json left")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user