diff --git a/internal/provider/clinepass_test.go b/internal/provider/clinepass_test.go index 99adfc84..fd386267 100644 --- a/internal/provider/clinepass_test.go +++ b/internal/provider/clinepass_test.go @@ -1,6 +1,7 @@ package provider import ( + "strings" "testing" "github.com/yetone/magpie/internal/catalog" @@ -20,6 +21,21 @@ func TestPlanModels(t *testing.T) { if got := p.planModels([]catalog.Model{{ID: "x/y"}}); len(got) != len(Preset("clinepass").Models) || got[0].ID != "cline-pass/glm-5.3" { t.Fatalf("plan's: %+v", got) } + // the Cline API's free models are kept beside the plan's, marked free, + // while its paid ones are still left out (lml on Discord) + got = p.planModels([]catalog.Model{{ID: "anthropic/claude-opus-5-5"}, {ID: "qwen/qwen3.8-27b:free"}, {ID: "google/gemma-4-31b-it:free"}}) + if n := len(Preset("clinepass").Models); len(got) != n+2 || got[0].ID != "cline-pass/glm-5.3" || got[n].ID != "qwen/qwen3.8-27b:free" || got[n+1].ID != "google/gemma-4-31b-it:free" { + t.Fatalf("free: %+v", got) + } + for _, m := range got { + if m.Free != strings.HasSuffix(m.ID, ":free") { + t.Fatalf("free flag: %+v", m) + } + } + // a free model costs nothing, not its tagless model's price + if pr, ok := p.ListPrice("qwen/qwen3.8-27b:free"); !ok || pr != (catalog.Price{}) { + t.Fatalf("free price: %+v %v", pr, ok) + } // another provider's list is left as it is o, _ := FromPreset("opencode-go") if got := o.planModels([]catalog.Model{{ID: "a"}, {ID: "b"}}); len(got) != 2 { diff --git a/internal/provider/models.go b/internal/provider/models.go index 891fd844..1f480d01 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -308,10 +308,15 @@ func (p Provider) planModels(ms []catalog.Model) []catalog.Model { if pr == nil || pr.Only == "" && (len(pr.Models) == 0 || len(ms) > 0) { return ms } - var out []catalog.Model + var out, free []catalog.Model for _, m := range ms { - if strings.HasPrefix(m.ID, pr.Only) { + switch { + case strings.HasPrefix(m.ID, pr.Only): out = append(out, m) + case p.freeTagged(m.ID): + // the vendor's free models, which the key is served too + m.Free = true + free = append(free, m) } } if len(out) == 0 { @@ -319,6 +324,7 @@ func (p Provider) planModels(ms []catalog.Model) []catalog.Model { out = append(out, catalog.Model{ID: id, Name: id}) } } + out = append(out, free...) for i, m := range out { // the vendor's window for its model, as models.dev has it if m.Context == 0 { @@ -331,6 +337,13 @@ func (p Provider) planModels(ms []catalog.Model) []catalog.Model { return out } +// freeTagged reports whether model is one of the vendor's free models p's +// preset keeps beside its plan's (PresetDef.FreeTag): Cline's ":free" ones. +func (p Provider) freeTagged(model string) bool { + pr := Preset(p.Preset) + return pr != nil && pr.Only != "" && pr.FreeTag != "" && strings.HasSuffix(model, pr.FreeTag) +} + // fixV1 adds the /v1 an OpenAI-style base URL was given without, when // the models were found only under it: base/models didn't answer and // base/v1/models did. The list is asked for at both, but a request goes @@ -598,6 +611,10 @@ var makerCatalogs = sync.OnceValue(func() []string { // at gpt-6-astra's or gemini-3.8-flash's maker's price, as a Claude // account is at Anthropic's. func (p Provider) ListPrice(model string) (catalog.Price, bool) { + if p.freeTagged(model) { + // served at no cost: not at the price of the model it is a tag of + return catalog.Price{}, true + } for _, m := range pricedNames(model) { if pr, ok := catalog.PricedBy(p.Catalogs(), m); ok { return pr, true diff --git a/internal/provider/presets.go b/internal/provider/presets.go index 37ea0a8d..2a0fbf2d 100644 --- a/internal/provider/presets.go +++ b/internal/provider/presets.go @@ -39,6 +39,10 @@ type PresetDef struct { // Models are the plan's, for when the list has none of them. Only string `json:"only,omitempty"` Models []string `json:"models,omitempty"` + // FreeTag, with Only, is what the ids of the vendor's free models end + // in: they are kept beside the plan's, marked Free and priced at + // nothing (Cline's ":free" models, which its key is served at no cost). + FreeTag string `json:"freeTag,omitempty"` // NoList: the vendor has no list of models to ask for (Bedrock's // runtime serves no /models), so Models are its list NoList bool `json:"noList,omitempty"` @@ -285,12 +289,13 @@ var presets = []PresetDef{ Note: "open coding models, $10/month", Website: "https://opencode.ai/docs/go", KeysURL: "https://opencode.ai/auth"}, // Cline's plan for open models, at the Cline API with a Cline key: the - // API lists only its paid models, not the plan's + // API lists only its paid models, not the plan's, and its free ones + // (":free"), which the key is served too {ID: "clinepass", Name: "ClinePass", Icon: "cline", Kind: KindRelay, Chat: "https://api.cline.bot/api/v1", Note: "open coding models, $9.99/month", Website: "https://docs.cline.bot/getting-started/clinepass", KeysURL: "https://app.cline.bot", - Only: "cline-pass/", + Only: "cline-pass/", FreeTag: ":free", Models: []string{"cline-pass/glm-5.3", "cline-pass/glm-5.3-flash", "cline-pass/kimi-k3", "cline-pass/deepseek-v4-pro", "cline-pass/deepseek-v4.1-flash", "cline-pass/mimo-v2.5", "cline-pass/mimo-v2.5-pro", "cline-pass/minimax-m3", "cline-pass/muse-spark-1.3-contributor", "cline-pass/qwen3.8-max", "cline-pass/qwen3.7-max", "cline-pass/qwen3.7-plus"}},