From 4091b9dfd5c29cba80840eb68fec840e5618fd84 Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 25 Sep 2026 01:34:27 +0800 Subject: [PATCH] =?UTF-8?q?dsh:=20switching=20the=20model=20works=20on=20d?= =?UTF-8?q?sh=200.1.5=20=E2=80=94=20it=20reads=20each=20profile's=20cordis?= =?UTF-8?q?.patch.yml=20(profiles/web,=20desktop,=20=E2=80=A6)=20rather=20?= =?UTF-8?q?than=20~/.dsh/config.yaml,=20so=20magpie=20writes=20there:=20ag?= =?UTF-8?q?ent-default-model=20for=20the=20model=20new=20sessions=20start?= =?UTF-8?q?=20on,=20and=20llm-deepseek=20naming=20its=20key=20as=20a=20cre?= =?UTF-8?q?dential=20(MAGPIE=5FAPI=5FKEY,=20in=20~/.dsh/.env)=20since=20th?= =?UTF-8?q?e=20entry=20no=20longer=20takes=20one;=20a=20model=20picked=20i?= =?UTF-8?q?nside=20dsh,=20saved=20in=20its=20settings,=20gives=20way=20to?= =?UTF-8?q?=20one=20picked=20in=20magpie;=20older=20dsh=20keeps=20config.y?= =?UTF-8?q?aml=20as=20before;=20dsh's=20own=20deepseek-flash=20is=20on=20t?= =?UTF-8?q?he=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/dsh.go | 163 +++++++++++++++++++++++++++++-------- internal/agent/dsh_test.go | 73 +++++++++++++++++ 2 files changed, 203 insertions(+), 33 deletions(-) diff --git a/internal/agent/dsh.go b/internal/agent/dsh.go index bb4f4a66..de6379ce 100644 --- a/internal/agent/dsh.go +++ b/internal/agent/dsh.go @@ -1,14 +1,17 @@ package agent -// DeepSeek Harness (dsh) boots from its shipped rows and then applies one -// personal patch list, ~/.dsh/config.yaml: a YAML list of {id, config} -// entries, each replacing the whole config of the row with that id. magpie -// points dsh at the gateway with such entries: llm-deepseek (endpoint, key, -// the catalog as its model list), agent-loop (the TUI's main agent) and -// api-gateway (the model `dsh -p` and `dsh web` start sessions on; the TUI -// has no such row and only notes that). All are marked as magpie's, the -// user's own entries stay as they are, and one magpie replaces is stashed -// and put back when it steps out. +// DeepSeek Harness (dsh) boots from its shipped rows and then applies a +// personal patch list: a YAML list of {id, config} entries, each replacing +// the whole config of the row with that id. Since 0.1.5 every profile has +// its own, ~/.dsh/profiles//cordis.patch.yml (web and desktop read it +// live); before that there was one, ~/.dsh/config.yaml. magpie points dsh at +// the gateway with such entries: llm-deepseek (endpoint, the catalog as its +// model list; the key is a credential named in apiKeyEnv, which magpie puts +// in ~/.dsh/.env) and agent-default-model (the model new sessions start on). +// Before 0.1.5 that model was agent-loop's main agent (the TUI) and +// api-gateway's route (`dsh -p`, `dsh web`), and the key sat in the entry. +// All are marked as magpie's, the user's own entries stay as they are, and +// one magpie replaces is stashed and put back when it steps out. import ( "encoding/json" @@ -16,6 +19,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "github.com/yetone/magpie/internal/edit" @@ -24,8 +28,12 @@ import ( const dshMark = "# magpie" +// dshKeyRef is the credential dsh signs gateway requests with. +const dshKeyRef = "MAGPIE_API_KEY" + // dshModels are the models dsh reaches on its own, as it ships them. var dshModels = []Option{ + {Value: "deepseek-flash", Label: "DeepSeek-V41-Flash", Icon: "deepseek-color", Group: "DeepSeek"}, {Value: "deepseek-v4-pro", Label: "DeepSeek-V4-Pro", Icon: "deepseek-color", Group: "DeepSeek"}, {Value: "deepseek-v4-flash", Label: "DeepSeek-V4-Flash", Icon: "deepseek-color", Group: "DeepSeek"}, } @@ -36,13 +44,20 @@ func dsh(home string) *Agent { dir = filepath.Join(home, ".dsh") } path := filepath.Join(dir, "config.yaml") + if files := dshProfiles(dir); len(files) > 0 { + path = files[0] + } return &Agent{ ID: "dsh", Name: "DeepSeek Harness", Icon: "deepseek-color", Aliases: []string{"deepseek-harness"}, Bin: "dsh", Dir: dir, Path: path, Notice: func() string { var notes []string if Running(`(^|/)dsh( |$)`) { - notes = append(notes, "dsh reads its config at start-up — restart open dsh sessions to use this.") + if len(dshProfiles(dir)) > 0 { + notes = append(notes, "New dsh sessions start on this; one already open keeps its model until you pick another in it. A dsh started with -p or in a terminal reads it at start-up.") + } else { + notes = append(notes, "dsh reads its config at start-up — restart open dsh sessions to use this.") + } } if dshSettingsEndpoint(filepath.Join(dir, "settings.yaml")) { notes = append(notes, "~/.dsh/settings.yaml sets its own DeepSeek endpoint or key, which dsh puts over magpie's; clear it in dsh's Models page to go through magpie.") @@ -51,8 +66,8 @@ func dsh(home string) *Agent { }, Fields: []Field{{ Key: "model", Label: "model", - Get: func() string { return dshGet(path) }, - Set: func(v string) error { return dshSet(path, v) }, + Get: func() string { return dshGet(dir) }, + Set: func(v string) error { return dshSet(dir, v) }, Options: func(map[string]string) []Option { return append(append([]Option{}, dshModels...), viaMagpie(magpieID+"/")...) }, @@ -60,6 +75,15 @@ func dsh(home string) *Agent { } } +// dshProfiles are the profiles' patch lists (dsh 0.1.5 on), web's first. +func dshProfiles(dir string) []string { + files, _ := filepath.Glob(filepath.Join(dir, "profiles", "*", "cordis.patch.yml")) + sort.SliceStable(files, func(i, j int) bool { + return filepath.Base(filepath.Dir(files[i])) == "web" && filepath.Base(filepath.Dir(files[j])) != "web" + }) + return files +} + // dshItem is one entry of the patch list, as its lines. type dshItem struct { id string @@ -127,20 +151,30 @@ func dshFind(items []dshItem, id string) int { var dshModelLine = regexp.MustCompile(`^\s+model:\s*(.+?)\s*$`) -func dshGet(path string) string { +// dshGet reads the model new sessions start on: the one last picked in dsh, +// saved in its settings, else the patch list's. +func dshGet(dir string) string { + files := dshProfiles(dir) + path, row := filepath.Join(dir, "config.yaml"), "agent-loop" + if len(files) > 0 { + path, row = files[0], "agent-default-model" + } _, items, err := dshRead(path) if err != nil { return "" } - i := dshFind(items, "agent-loop") - if i < 0 { - return "" - } model := "" - for _, l := range items[i].lines { - if m := dshModelLine.FindStringSubmatch(l); m != nil { - model = yamlScalar(m[1]) - break + if len(files) > 0 { + if m := edit.GetYAMLMap(filepath.Join(dir, "settings.yaml"), "agent-default-model"); m["model"] != "" { + model = m["model"] + } + } + if i := dshFind(items, row); i >= 0 && model == "" { + for _, l := range items[i].lines { + if m := dshModelLine.FindStringSubmatch(l); m != nil { + model = yamlScalar(m[1]) + break + } } } if model == "" { @@ -168,17 +202,57 @@ func yamlScalar(v string) string { func dshStashKey(path, id string) string { return "dsh:" + path + ":" + id } -// dshSet writes v as the main agent's model: a catalog model through the -// gateway, one of dsh's own models directly, or "" for dsh's own default. -func dshSet(path, v string) error { +// dshSet writes v as the model new sessions start on: a catalog model +// through the gateway, one of dsh's own models directly, or "" for dsh's own +// default. Every profile gets it; config.yaml only where there are none. +func dshSet(dir, v string) error { + ref, viaGateway := strings.CutPrefix(v, magpieID+"/") + if viaGateway && !isMagpie(ref) { + return fmt.Errorf("unknown model %q", v) + } + legacy := filepath.Join(dir, "config.yaml") + files := dshProfiles(dir) + if len(files) == 0 { + return dshSetFile(legacy, v, false) + } + for _, f := range files { + if err := dshSetFile(f, v, true); err != nil { + return err + } + } + // what an older dsh was given is no use now + if _, err := os.Stat(legacy); err == nil { + if err := dshSetFile(legacy, "", false); err != nil { + return err + } + } + env := filepath.Join(dir, ".env") + if viaGateway { + if err := edit.SetEnvFile(env, edit.KV{Path: dshKeyRef, Value: gateway.Token}); err != nil { + return err + } + } else if _, ok := edit.GetEnvFile(env, dshKeyRef); ok { + if err := edit.DelEnvFile(env, dshKeyRef); err != nil { + return err + } + } + // a model picked in dsh is saved in its settings and goes over the + // patch list; picking one here takes over from it + settings := filepath.Join(dir, "settings.yaml") + if v != "" && edit.GetYAMLMap(settings, "agent-default-model") != nil { + return edit.DelYAML(settings, "agent-default-model") + } + return nil +} + +// dshSetFile writes v into one patch list; modern is the layout of dsh +// 0.1.5 on. +func dshSetFile(path, v string, modern bool) error { head, items, err := dshRead(path) if err != nil { return err } ref, viaGateway := strings.CutPrefix(v, magpieID+"/") - if viaGateway && !isMagpie(ref) { - return fmt.Errorf("unknown model %q", v) - } put := func(id string, lines []string) { i := dshFind(items, id) if i >= 0 && !items[i].magpie { @@ -205,11 +279,18 @@ func dshSet(path, v string) error { switch { case v == "": + drop("agent-default-model") drop("api-gateway") drop("agent-loop") drop("llm-deepseek") + case modern && viaGateway: + put("llm-deepseek", dshProviderLines(true)) + put("agent-default-model", dshDefaultLines(ref)) + case modern: + drop("llm-deepseek") + put("agent-default-model", dshDefaultLines(v)) case viaGateway: - put("llm-deepseek", dshProviderLines()) + put("llm-deepseek", dshProviderLines(false)) put("agent-loop", dshLoopLines(ref)) put("api-gateway", dshRouteLines(ref)) default: @@ -223,11 +304,11 @@ func dshSet(path, v string) error { for _, it := range items { out = append(out, it.lines...) } - if len(items) == 0 && len(head) == 0 { + if len(items) == 0 { if _, err := os.Stat(path); err != nil { return nil // nothing was there, nothing to write } - out = append(out, "[]") + out = append(out, "[]") // dsh wants a list, even an empty one } return edit.WriteAtomic(path, []byte(strings.Join(out, "\n")+"\n")) } @@ -238,12 +319,17 @@ func yamlQuote(s string) string { } // dshProviderLines is the llm-deepseek entry pointing dsh at the gateway, -// with the catalog as the models its /model offers. -func dshProviderLines() []string { +// with the catalog as the models its /model offers. Since 0.1.5 the key is a +// credential the entry names rather than holds. +func dshProviderLines(modern bool) []string { + key := " apiKey: " + yamlQuote(gateway.Token) + if modern { + key = " apiKeyEnv: " + dshKeyRef + } lines := []string{ "- id: llm-deepseek " + dshMark, " config:", - " apiKey: " + yamlQuote(gateway.Token), + key, " baseURL: " + yamlQuote(gatewayV1()), " thinking: enabled", " reasoningEffort: high", @@ -259,6 +345,17 @@ func dshProviderLines() []string { return lines } +// dshDefaultLines is the agent-default-model entry: the model new sessions +// start on, in every entry point. +func dshDefaultLines(model string) []string { + return []string{ + "- id: agent-default-model " + dshMark, + " config:", + " provider: deepseek-official", + " model: " + yamlQuote(model), + } +} + // dshLoopLines is the agent-loop entry dsh ships, with model in place. func dshLoopLines(model string) []string { return []string{ diff --git a/internal/agent/dsh_test.go b/internal/agent/dsh_test.go index 2ab25fac..594205b3 100644 --- a/internal/agent/dsh_test.go +++ b/internal/agent/dsh_test.go @@ -82,6 +82,79 @@ func TestDsh(t *testing.T) { } } +// Since 0.1.5 each profile has its own patch list, the key is a credential +// and new sessions start on agent-default-model. +func TestDshProfiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("DSH_HOME", "") + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash"}}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".dsh") + template := "# Your patch layer for this dsh profile.\n[]\n" + web, desktop := filepath.Join(dir, "profiles", "web", "cordis.patch.yml"), filepath.Join(dir, "profiles", "desktop", "cordis.patch.yml") + for _, p := range []string{web, desktop} { + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(template), 0o644) + } + legacy := filepath.Join(dir, "config.yaml") + os.WriteFile(legacy, []byte("- id: agent-loop # magpie\n config:\n agents: []\n"), 0o644) + settings := filepath.Join(dir, "settings.yaml") + os.WriteFile(settings, []byte("ui:\n theme: dark\nagent-default-model:\n provider: deepseek-official\n model: deepseek-flash\n"), 0o644) + read := func(p string) string { b, _ := os.ReadFile(p); return string(b) } + + f := dsh(home).Field("model") + if f.Get() != "deepseek-flash" { + t.Fatalf("the model picked in dsh: %q", f.Get()) + } + if err := f.Set("magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + for _, p := range []string{web, desktop} { + s := read(p) + for _, want := range []string{"# Your patch layer", "- id: llm-deepseek # magpie", "apiKeyEnv: " + dshKeyRef, `baseURL: "http://`, "- id: agent-default-model # magpie", "provider: deepseek-official", `model: "deepseek/pro"`} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in %s:\n%s", want, p, s) + } + } + if strings.Contains(s, "[]") || strings.Contains(s, "apiKey:") || strings.Contains(s, "agent-loop") { + t.Fatalf("%s:\n%s", p, s) + } + } + if got := read(legacy); got != "[]\n" { + t.Fatalf("config.yaml should lose magpie's entries: %q", got) + } + if !strings.Contains(read(filepath.Join(dir, ".env")), dshKeyRef+"=magpie") { + t.Fatal("no key for the gateway") + } + if s := read(settings); strings.Contains(s, "agent-default-model") || !strings.Contains(s, "theme: dark") { + t.Fatalf("settings:\n%s", s) + } + if f.Get() != "magpie/deepseek/pro" { + t.Fatalf("get: %q", f.Get()) + } + + if err := f.Set("deepseek-v4-pro"); err != nil { + t.Fatal(err) + } + if s := read(web); strings.Contains(s, "llm-deepseek") || !strings.Contains(s, `model: "deepseek-v4-pro"`) || f.Get() != "deepseek-v4-pro" { + t.Fatalf("own model: %q\n%s", f.Get(), s) + } + if strings.Contains(read(filepath.Join(dir, ".env")), dshKeyRef) { + t.Fatal("the key outlived the gateway") + } + + if err := f.Set(""); err != nil { + t.Fatal(err) + } + if got := read(web); got != template { + t.Fatalf("reset should leave the template as it was:\n%q", got) + } +} + func TestDshSettingsEndpoint(t *testing.T) { p := filepath.Join(t.TempDir(), "settings.yaml") os.WriteFile(p, []byte("ui:\n theme: dark\nllm-deepseek:\n thinking: enabled\n"), 0o644)