agents: Cline lists every magpie model — its built-in openai-compatible provider knows only gpt-4o, so /model offered that alone and the rest had to be typed in one by one. magpie now puts its models in models.json beside providers.json, as the entry Cline's own migration writes for openai-compatible (provider name, baseUrl, defaultModelId, and the models with their context, output, images and reasoning), which takes the place of the built-in list; the user's entry there is stashed and put back when magpie steps out, and Sync keeps the list current. Tested live with Cline 3.0.65's TUI against a sandbox gateway: /model shows provider magpie with every magpie model, picking one runs the turn on it, and magpie reads the pick back

This commit is contained in:
yetone committed 2026-09-27 07:30:59 +08:00
1 parent cae88f031c
commit 84f001da08
2 files changed
+92 -10

No files matched your search

+61 -10
View File
@@ -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
}
}
+31
View File
@@ -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)
}
}