diff --git a/internal/agent/cline.go b/internal/agent/cline.go index 07c8bd2c..30218318 100644 --- a/internal/agent/cline.go +++ b/internal/agent/cline.go @@ -13,9 +13,12 @@ package agent // refused when a session starts ("Unknown or disabled provider", // cline/cline#14180), so magpie takes Cline's built-in openai-compatible // provider, which reads the models from the gateway's /v1/models, and makes -// it the one in use. What was there, and the provider in use, are stashed -// and put back when magpie steps out. Cline's requests name only the AI SDK, -// so the header says they are Cline's. +// it the one in use. That provider lists only gpt-4o, so magpie's models +// are put in models.json beside it, as the entry Cline's own migration writes +// for it ({"provider":{"name","baseUrl","defaultModelId"},"models":{…}}), +// which takes the place of the built-in list. What was there, and the +// provider in use, are stashed and put back when magpie steps out. Cline's +// requests name only the AI SDK, so the header says they are Cline's. import ( "encoding/json" @@ -37,6 +40,7 @@ func cline(home string) *Agent { dir = filepath.Join(home, ".cline") } path := filepath.Join(dir, "data", "settings", "providers.json") + models := filepath.Join(dir, "data", "settings", "models.json") key := "cline:" + path + ":" slot := "providers." + clineSlot get := func(k string) string { v, _ := edit.GetJSON(path, k); return v } @@ -48,6 +52,13 @@ func cline(home string) *Agent { // use the user had before magpie restore := func() error { entry, last := unstash(key+"entry"), unstash(key+"lastUsedProvider") + if list := unstash(key + "models"); list != "" { + if err := edit.SetJSON(models, edit.KV{Path: "providers." + clineSlot, Value: json.RawMessage(list)}); err != nil { + return err + } + } else if err := edit.DelJSON(models, "providers."+clineSlot); err != nil { + return err + } if entry != "" { if err := edit.SetJSON(path, edit.KV{Path: slot, Value: json.RawMessage(entry)}); err != nil { return err @@ -76,7 +87,10 @@ func cline(home string) *Agent { if !onMagpie() { return nil } - return syncJSON(path, slot+".settings.baseUrl", func() any { return gatewayV1() }) + if err := syncJSON(path, slot+".settings.baseUrl", func() any { return gatewayV1() }); err != nil { + return err + } + return syncJSON(models, "providers."+clineSlot, func() any { return clineModels(get(slot + ".settings.model")) }) }, Notice: func() string { if Running(`(^|/)cline( |$)`) { @@ -117,12 +131,18 @@ func cline(home string) *Agent { if ref, ok := cutMagpie(v); ok { effort := get("providers." + own() + ".settings.reasoning.effort") if !onMagpie() { + list, _ := edit.GetJSON(models, "providers."+clineSlot) stash(map[string]string{ key + "entry": get(slot), key + "lastUsedProvider": inUse(), + key + "models": list, }) } - return clineWrite(path, + if err := clineWrite(models, `{"version":1,"providers":{}}`, + edit.KV{Path: "providers." + clineSlot, Value: clineModels(ref)}); err != nil { + return err + } + return clineWrite(path, providersEmpty, edit.KV{Path: slot, Value: clineProvider(ref, effort)}, edit.KV{Path: "lastUsedProvider", Value: clineSlot}) } @@ -132,7 +152,7 @@ func cline(home string) *Agent { } } p := own() - return clineWrite(path, + return clineWrite(path, providersEmpty, edit.KV{Path: "providers." + p + ".settings.provider", Value: p}, edit.KV{Path: "providers." + p + ".settings.model", Value: v}) }, @@ -155,7 +175,7 @@ func cline(home string) *Agent { if v == "" { return edit.DelJSON(path, k) } - return clineWrite(path, edit.KV{Path: k + ".effort", Value: v}) + return clineWrite(path, providersEmpty, edit.KV{Path: k + ".effort", Value: v}) }, Options: func(map[string]string) []Option { return static("none", "low", "medium", "high", "xhigh") @@ -183,13 +203,44 @@ func clineProvider(model, effort string) map[string]any { return map[string]any{"settings": s, "updatedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), "tokenSource": "manual"} } -// clineWrite sets kvs in providers.json, which Cline keeps private. -func clineWrite(path string, kvs ...edit.KV) error { +// clineModels is magpie's entry in models.json: every magpie model, model +// the one the provider starts on. +func clineModels(model string) map[string]any { + ms := map[string]any{} + for _, m := range magpieModels("cline") { + caps := []string{"streaming", "tools"} + if m.Images { + caps = append(caps, "images") + } + if len(m.Efforts) > 0 { + caps = append(caps, "reasoning") + } + e := map[string]any{"id": m.ID, "name": m.Name, "capabilities": caps} + if m.Context > 0 { + e["contextWindow"] = m.Context + } + if m.Output > 0 { + e["maxTokens"] = m.Output + } + ms[m.ID] = e + } + return map[string]any{ + "provider": map[string]any{"name": "magpie", "baseUrl": gatewayV1(), "defaultModelId": model}, + "models": ms, + } +} + +// providersEmpty is a providers.json with nothing in it. +const providersEmpty = `{"version":1,"modes":{},"providers":{}}` + +// clineWrite sets kvs in a file of Cline's settings, which it keeps +// private, made from empty when there is none. +func clineWrite(path, empty string, kvs ...edit.KV) error { if _, err := os.Stat(path); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - if err := os.WriteFile(path, []byte("{\"version\":1,\"modes\":{},\"providers\":{}}\n"), 0o600); err != nil { + if err := os.WriteFile(path, []byte(empty+"\n"), 0o600); err != nil { return err } } diff --git a/internal/agent/cline_test.go b/internal/agent/cline_test.go index 77602ed3..272cd254 100644 --- a/internal/agent/cline_test.go +++ b/internal/agent/cline_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/yetone/magpie/internal/gateway" @@ -34,7 +35,22 @@ func TestCline(t *testing.T) { json.Unmarshal(b, &f) return f, string(b) } + mpath := filepath.Join(filepath.Dir(path), "models.json") + type list struct { + Provider map[string]any `json:"provider"` + Models map[string]map[string]any `json:"models"` + } + models := func() (map[string]list, string) { + var f struct { + Providers map[string]list `json:"providers"` + } + b, _ := os.ReadFile(mpath) + json.Unmarshal(b, &f) + return f.Providers, string(b) + } os.MkdirAll(filepath.Dir(path), 0o700) + mine := `{"provider":{"name":"OpenAI Compatible","baseUrl":"https://x/v1","defaultModelId":"mine"},"models":{"mine":{"id":"mine","name":"mine"}}}` + os.WriteFile(mpath, []byte(`{"version":1,"providers":{"openai-compatible":`+mine+`,"ollama":{"models":{"q":{"id":"q"}}}}}`), 0o600) os.WriteFile(path, []byte(`{"version":1,"lastUsedProvider":"anthropic","modes":{},"providers":{ "anthropic":{"settings":{"provider":"anthropic","apiKey":"sk-a","model":"claude-opus-5","reasoning":{"effort":"high"}},"updatedAt":"2026-09-01T00:00:00.000Z","tokenSource":"manual"}, "openai-compatible":{"settings":{"provider":"openai-compatible","apiKey":"sk-o","model":"mine","baseUrl":"https://x/v1"},"updatedAt":"2026-09-01T00:00:00.000Z","tokenSource":"manual"}}}`), 0o600) @@ -54,6 +70,13 @@ func TestCline(t *testing.T) { c.Providers["anthropic"].Settings["apiKey"] != "sk-a" { t.Fatalf("magpie:\n%s", raw) } + // Cline's openai-compatible lists only gpt-4o: magpie's models take + // its place in models.json, the user's other entries kept + ml, mraw := models() + if m := ml["openai-compatible"]; m.Provider["baseUrl"] != gatewayV1() || m.Provider["defaultModelId"] != "deepseek/pro" || + m.Models["deepseek/pro"] == nil || m.Models["deepseek/flash"] == nil || m.Models["mine"] != nil || ml["ollama"].Models["q"] == nil { + t.Fatalf("models:\n%s", mraw) + } if f.Get() != "magpie/deepseek/pro" || e.Get() != "high" || a.Check() != "" { t.Fatalf("get: %q %q %q", f.Get(), e.Get(), a.Check()) } @@ -79,10 +102,15 @@ func TestCline(t *testing.T) { c.Providers["openai-compatible"].Settings["apiKey"] != "sk-o" || c.Providers["openai-compatible"].Settings["baseUrl"] != "https://x/v1" { t.Fatalf("own:\n%s", raw) } + if ml, mraw := models(); ml["openai-compatible"].Models["mine"] == nil || ml["openai-compatible"].Models["deepseek/pro"] != nil || + ml["openai-compatible"].Provider["baseUrl"] != "https://x/v1" || ml["ollama"].Models["q"] == nil { + t.Fatalf("own models:\n%s", mraw) + } // reset from magpie, with no Cline settings before it: nothing of // magpie's is left os.Remove(path) + os.Remove(mpath) if f.Get() != "" { t.Fatalf("empty get: %q", f.Get()) } @@ -103,4 +131,7 @@ func TestCline(t *testing.T) { if c.LastUsed != "" || len(c.Providers) != 0 || c.Version != 1 { t.Fatalf("reset:\n%s", raw) } + if ml, mraw := models(); len(ml) != 0 || !strings.Contains(mraw, `"version"`) { + t.Fatalf("reset models:\n%s", mraw) + } }