dsh: switching the model works on dsh 0.1.5 — it reads each profile's cordis.patch.yml (profiles/web, desktop, …) rather than ~/.dsh/config.yaml, so magpie writes there: agent-default-model for the model new sessions start on, and llm-deepseek naming its key as a credential (MAGPIE_API_KEY, in ~/.dsh/.env) since the entry no longer takes one; a model picked inside dsh, saved in its settings, gives way to one picked in magpie; older dsh keeps config.yaml as before; dsh's own deepseek-flash is on the list

This commit is contained in:
yetone committed 2026-09-25 01:34:27 +08:00
1 parent b498b1fb02
commit 4091b9dfd5
2 files changed
+203 -33

No files matched your search

+130 -33
View File
@@ -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/<name>/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{
+73
View File
@@ -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)