From a3962602049807cfaf92fd6c61d9b6038e9aff9c Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 2 Oct 2026 10:48:45 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20ClinePass=20lists=20the=20Cline=20?= =?UTF-8?q?API's=20free=20models=20beside=20the=20plan's=20(lml=20on=20Dis?= =?UTF-8?q?cord:=20Cline=E9=87=8C=E9=9D=A2=E7=9A=84=E5=85=8D=E8=B4=B9?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=98=AF=E5=90=A6=E4=B9=9F=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=91=A2=EF=BC=9F)=20=E2=80=94=20a=20Cline?= =?UTF-8?q?=20key=20is=20served=20the=20API's=20":free"=20models=20(qwen/q?= =?UTF-8?q?wen3.8-27b:free,=20google/gemma-4-31b-it:free,=20nvidia/nemotro?= =?UTF-8?q?n-3-super-120b-a12b:free=E2=80=A6)=20at=20no=20cost,=20so=20the?= =?UTF-8?q?=20preset's=20new=20FreeTag=20":free"=20keeps=20the=20ids=20of?= =?UTF-8?q?=20the=20fetched=20list=20ending=20in=20it=20after=20the=20plan?= =?UTF-8?q?'s=20cline-pass/=20models=20(the=20plan's=20own=20list=20when?= =?UTF-8?q?=20the=20API=20names=20none=20of=20them,=20as=20before),=20mark?= =?UTF-8?q?ed=20Free,=20which=20the=20model=20list=20already=20badges=20FR?= =?UTF-8?q?EE=20("free:=20it=20doesn't=20use=20the=20plan's=20credits")=20?= =?UTF-8?q?as=20it=20does=20WorkBuddy's=20and=20Command=20Code's=20free=20?= =?UTF-8?q?models.=20Its=20paid=20models=20are=20still=20left=20out,=20and?= =?UTF-8?q?=20before=20the=20list=20is=20fetched=20only=20the=20plan's=20m?= =?UTF-8?q?odels=20show,=20as=20before:=20no=20free=20list=20is=20written?= =?UTF-8?q?=20down.=20ListPrice=20prices=20a=20FreeTag=20model=20at=20zero?= =?UTF-8?q?,=20where=20it=20was=20priced=20as=20the=20model=20without=20it?= =?UTF-8?q?s=20tag=20(":free"=20is=20cut=20off=20when=20matching=20models.?= =?UTF-8?q?dev),=20so=20the=20usage=20ledger=20charges=20it=20nothing.=20T?= =?UTF-8?q?he=20free=20models=20were=20not=20tried=20with=20a=20real=20Cli?= =?UTF-8?q?ne=20account=20(no=20key=20at=20hand).=20TestPlanModels=20fails?= =?UTF-8?q?=20without=20the=20change=20(the=20free=20models=20are=20droppe?= =?UTF-8?q?d,=20none=20is=20marked=20Free,=20and=20one=20has=20no=20price)?= =?UTF-8?q?.=20go=20vet=20./internal/...,=20go=20build=20-tags=20nogui=20(?= =?UTF-8?q?darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go=20build,=20go=20?= =?UTF-8?q?test=20-tags=20nogui=20./internal/provider/=20./internal/gatewa?= =?UTF-8?q?y/=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/clinepass_test.go | 16 ++++++++++++++++ internal/provider/models.go | 21 +++++++++++++++++++-- internal/provider/presets.go | 9 +++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) 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"}},