From 2baad5366c2990f43e376c2ca0a373baf03d794b Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 28 Sep 2026 20:09:01 +0800 Subject: [PATCH] =?UTF-8?q?provider:=20Antigravity's=20models=20at=20sever?= =?UTF-8?q?al=20levels=20are=20one=20model=20with=20those=20levels=20(#150?= =?UTF-8?q?:=20=E7=BB=9F=E4=B8=80=20Antigravity=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=8F=98=E4=BD=93=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E6=A1=A3=E4=BD=8D=E7=9A=84=E6=98=A0=E5=B0=84)=20?= =?UTF-8?q?=E2=80=94=20Antigravity=20lists=20gemini-3.7-flash-low,=20-medi?= =?UTF-8?q?um=20and=20-high=20as=20three=20models,=20"Gemini=203.7=20Flash?= =?UTF-8?q?=20(Low)"=20=E2=80=A6,=20so=20a=20client=20picked=20a=20level?= =?UTF-8?q?=20twice:=20once=20in=20the=20model,=20once=20as=20its=20reason?= =?UTF-8?q?ing=20effort,=20with=20no=20telling=20which=20won.=20Now=20ids?= =?UTF-8?q?=20that=20are=20one=20id=20with=20an=20effort=20word=20after=20?= =?UTF-8?q?it=20(-low,=20-medium,=20-high,=20-extra-low,=20-minimal,=20-xh?= =?UTF-8?q?igh)=20are=20one=20model,=20gemini-3.7-flash=20"Gemini=203.7=20?= =?UTF-8?q?Flash",=20declaring=20only=20the=20levels=20there=20are=20(low,?= =?UTF-8?q?medium,high;=20Gemini=203.1=20Pro=20low,high),=20so=20Codex,=20?= =?UTF-8?q?Pi=20and=20the=20rest=20get=20reasoning=20levels=20for=20it.=20?= =?UTF-8?q?The=20level=20a=20variant=20is=20at=20is=20what=20Antigravity's?= =?UTF-8?q?=20name=20says=20where=20each=20name=20says=20a=20different=20o?= =?UTF-8?q?ne=20(gemini-3.5-flash-extra-low=20is=20"(Low)",=20gemini-3.5-f?= =?UTF-8?q?lash-low=20"(Medium)"),=20else=20its=20id's=20word;=20a=20model?= =?UTF-8?q?=20only=20named=20like=20another=20(gemini-3-flash-agent,=20"Ge?= =?UTF-8?q?mini=203.5=20Flash=20(High)";=20gemini-pro-agent)=20or=20with?= =?UTF-8?q?=20no=20sibling=20(gpt-oss-120b-medium)=20stays=20as=20listed.?= =?UTF-8?q?=20A=20request=20asks=20for=20the=20variant=20the=20effort=20pi?= =?UTF-8?q?cks,=20an=20effort=20it=20lacks=20at=20the=20nearest=20level=20?= =?UTF-8?q?(xhigh=20is=20high;=20medium=20on=20low/high=20is=20high);=20wi?= =?UTF-8?q?th=20none=20asked,=20the=20id=20without=20a=20level=20if=20Anti?= =?UTF-8?q?gravity=20has=20one,=20else=20high=20=E2=80=94=20what=20Gemini?= =?UTF-8?q?=203=20does=20unasked=20=E2=80=94=20else=20the=20highest.=20A?= =?UTF-8?q?=20variant's=20request=20carries=20no=20thinkingLevel,=20so=20t?= =?UTF-8?q?he=20id=20and=20the=20level=20can't=20disagree.=20An=20old=20va?= =?UTF-8?q?riant=20id=20still=20works:=20as=20named=20with=20no=20effort,?= =?UTF-8?q?=20and=20moved=20to=20the=20effort=20asked=20for.=20Saved=20pic?= =?UTF-8?q?ks=20and=20agents=20set=20to=20a=20variant=20(antigravity/gemin?= =?UTF-8?q?i-3.7-flash-high)=20are=20moved=20to=20the=20family,=20at=20tha?= =?UTF-8?q?t=20level=20where=20the=20agent's=20own=20is=20unset,=20as=20Cu?= =?UTF-8?q?rsor's=20are.=20For=20Gemini=203=20models=20without=20variants,?= =?UTF-8?q?=20medium=20now=20goes=20as=20medium=20to=20Flash=20(which=20ta?= =?UTF-8?q?kes=20minimal/low/medium/high)=20and=20still=20as=20high=20to?= =?UTF-8?q?=20Pro=20(low/high).=20Tested=20against=20the=20list=20an=20acc?= =?UTF-8?q?ount's=20fetchAvailableModels=20gave=20(sandbox=20cache)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/legacy.go | 5 + internal/gateway/codeassist.go | 71 +++++- internal/gateway/codeassist_test.go | 51 ++++ internal/provider/antigravity_models.go | 241 +++++++++++++++++++ internal/provider/antigravity_models_test.go | 128 ++++++++++ internal/provider/models.go | 4 + internal/provider/provider.go | 3 + main.go | 1 + 8 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 internal/provider/antigravity_models.go create mode 100644 internal/provider/antigravity_models_test.go diff --git a/internal/agent/legacy.go b/internal/agent/legacy.go index 19c3aa06..6a428ea8 100644 --- a/internal/agent/legacy.go +++ b/internal/agent/legacy.go @@ -70,6 +70,11 @@ func RenameLegacy() { // Cursor's id. It runs at start-up; errors are swallowed as RenameLegacy's. func MoveCursorEfforts() { moveEfforts("cursor", provider.CursorBase) } +// MoveAntigravityEfforts does the same for Antigravity's ids at a level +// (antigravity/gemini-3.7-flash-high → antigravity/gemini-3.7-flash, at +// high when the agent's effort is unset and it has that one). +func MoveAntigravityEfforts() { moveEfforts("antigravity", provider.AntigravityBase) } + func moveEfforts(pid string, base func(string) (string, string, bool)) { offers := func(opts []Option, v string) bool { return slices.ContainsFunc(opts, func(o Option) bool { return o.Value == v }) diff --git a/internal/gateway/codeassist.go b/internal/gateway/codeassist.go index bd36d4a2..dd86c2a6 100644 --- a/internal/gateway/codeassist.go +++ b/internal/gateway/codeassist.go @@ -4,6 +4,8 @@ import ( "encoding/json" "regexp" "strings" + + "github.com/yetone/magpie/internal/provider" ) // ---- Google Code Assist (provider side) -------------------------------------- @@ -21,10 +23,58 @@ const skipSignature = "skip_thought_signature_validator" var unsafeToolID = regexp.MustCompile(`[^a-zA-Z0-9_-]`) +// antigravityVariants are the ids of Antigravity's a model magpie offers +// stands for, by level, and antigravityBaseOf the model and level one of +// those ids is; vars so tests can stand in for them. +var ( + antigravityVariants = provider.AntigravityVariants + antigravityBaseOf = provider.AntigravityBase +) + +// antigravityModelID is Antigravity's id for a model magpie offers, at +// the effort asked for: the family's variant at it, or at the nearest +// level it has; with none asked, the family's default. One of +// Antigravity's own ids at a level (gemini-3.7-flash-low, one an agent +// was set to before) goes as it is when no effort is asked, and else is +// the family's variant at the effort asked for: the effort the client +// picks wins, as it does for the family. Any other id goes as it is. +func antigravityModelID(model, effort string) string { + if base, _, ok := antigravityBaseOf(model); ok { + if effort == "" { + return model + } + model = base + } + vs, ok := antigravityVariants(model) + if !ok { + return model + } + if effort == "" { + return vs[""] + } + var levels []string + for _, l := range effortRank { + if vs[l] != "" { + levels = append(levels, l) + } + } + if id := vs[fitEffort(effort, levels)]; id != "" { + return id + } + return vs[""] +} + // buildCodeAssist builds the Code Assist envelope for a request, for the -// app the account belongs to: "gemini" or "antigravity". +// app the account belongs to: "gemini" or "antigravity". On Antigravity a +// model that is a family of levels is asked for as the variant the effort +// picks (antigravityModelID). func buildCodeAssist(r *Request, model, agent string) []byte { ag := agent == "antigravity" + fixed := false // the id says the level it thinks at + if ag { + model = antigravityModelID(model, r.Effort) + _, _, fixed = antigravityBaseOf(model) + } claude := strings.Contains(strings.ToLower(model), "claude") toolID := func(id string) string { if !ag || id == "" { @@ -142,7 +192,7 @@ func buildCodeAssist(r *Request, model, agent string) []byte { if len(r.Stop) > 0 { gen["stopSequences"] = r.Stop } - if tc := thinkingConfig(r, model, claude); tc != nil { + if tc := thinkingConfig(r, model, claude, fixed); tc != nil { gen["thinkingConfig"] = tc // Claude's answer has to have room past its thinking if b, ok := tc["thinkingBudget"].(int); ok && claude && gen["maxOutputTokens"] == nil { @@ -157,8 +207,14 @@ func buildCodeAssist(r *Request, model, agent string) []byte { } // thinkingConfig says how hard the model should think: a level for -// Gemini 3, a budget for the rest. A model that doesn't think gets none. -func thinkingConfig(r *Request, model string, claude bool) map[string]any { +// Gemini 3, a budget for the rest. A model that doesn't think gets none, +// and one whose id already says its level (fixed: Antigravity's +// gemini-3.7-flash-low) gets no level that could say otherwise — the +// effort asked for picked that id. +// +// Gemini 3's levels: Flash takes minimal, low, medium and high, so medium +// goes as medium; Pro takes low and high only, so medium goes up to high. +func thinkingConfig(r *Request, model string, claude, fixed bool) map[string]any { m := strings.ToLower(model) if strings.HasPrefix(m, "gpt-oss") || claude && !strings.Contains(m, "thinking") { return nil @@ -171,10 +227,13 @@ func thinkingConfig(r *Request, model string, claude bool) map[string]any { } tc := map[string]any{"includeThoughts": true} if strings.HasPrefix(m, "gemini-3") || strings.HasPrefix(m, "gemini-pro-agent") { - if r.Effort != "" { + if r.Effort != "" && !fixed { level := "high" - if r.Effort == "low" { + switch { + case r.Effort == "low": level = "low" + case r.Effort == "medium" && strings.Contains(m, "flash"): + level = "medium" } tc["thinkingLevel"] = level } diff --git a/internal/gateway/codeassist_test.go b/internal/gateway/codeassist_test.go index c9d5894a..ead77a58 100644 --- a/internal/gateway/codeassist_test.go +++ b/internal/gateway/codeassist_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "testing" + + "github.com/yetone/magpie/internal/catalog" ) func codeAssistRequest(t *testing.T) *Request { @@ -141,3 +143,52 @@ func TestCodeAssistDecoder(t *testing.T) { t.Errorf("error events = %+v", errs) } } + +// On Antigravity a model that is one of its families of levels is asked +// for as the variant the effort picks, with no thinking level that could +// say otherwise; an old variant id moves with an effort asked; Gemini 3 +// Flash takes medium as medium, Pro as high. +func TestCodeAssistAntigravityLevels(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + var raw []catalog.Model + for _, l := range []string{"gemini-3-flash - Gemini 3 Flash", "gemini-3.1-pro-high - Gemini 3.1 Pro (High)", "gemini-3.1-pro-low - Gemini 3.1 Pro (Low)", + "gemini-3.7-flash-high - Gemini 3.7 Flash (High)", "gemini-3.7-flash-low - Gemini 3.7 Flash (Low)", + "gemini-3.7-flash-medium - Gemini 3.7 Flash (Medium)", "gemini-pro-agent - Gemini 3.1 Pro (High)"} { + id, name, _ := strings.Cut(l, " - ") + raw = append(raw, catalog.Model{ID: id, Name: name}) + } + if err := catalog.SaveLive("antigravity", "", raw); err != nil { + t.Fatal(err) + } + // level: "" no thinkingConfig, "-" thinking at the level the id says + for _, c := range []struct{ model, effort, agent, want, level string }{ + {"gemini-3.7-flash", "low", "antigravity", "gemini-3.7-flash-low", "-"}, + {"gemini-3.7-flash", "medium", "antigravity", "gemini-3.7-flash-medium", "-"}, + {"gemini-3.7-flash", "xhigh", "antigravity", "gemini-3.7-flash-high", "-"}, + {"gemini-3.7-flash", "", "antigravity", "gemini-3.7-flash-high", ""}, + {"gemini-3.7-flash-high", "", "antigravity", "gemini-3.7-flash-high", ""}, + {"gemini-3.7-flash-high", "low", "antigravity", "gemini-3.7-flash-low", "-"}, + {"gemini-3.1-pro", "medium", "antigravity", "gemini-3.1-pro-high", "-"}, + {"gemini-3-flash", "medium", "antigravity", "gemini-3-flash", "medium"}, + {"gemini-pro-agent", "medium", "antigravity", "gemini-pro-agent", "high"}, + {"gemini-pro-agent", "low", "antigravity", "gemini-pro-agent", "low"}, + {"gemini-3.7-flash", "low", "gemini", "gemini-3.7-flash", "low"}, + } { + r := codeAssistRequest(t) + r.Thinking, r.Effort = false, c.effort + var env struct { + Model string `json:"model"` + Request map[string]any `json:"request"` + } + json.Unmarshal(buildCodeAssist(r, c.model, c.agent), &env) + gen, _ := env.Request["generationConfig"].(map[string]any) + tc, _ := gen["thinkingConfig"].(map[string]any) + level, _ := tc["thinkingLevel"].(string) + if tc != nil && level == "" { + level = "-" + } + if env.Model != c.want || level != c.level { + t.Errorf("%s at %q on %s: %s at %q, want %s at %q", c.model, c.effort, c.agent, env.Model, level, c.want, c.level) + } + } +} diff --git a/internal/provider/antigravity_models.go b/internal/provider/antigravity_models.go new file mode 100644 index 00000000..08f49dbf --- /dev/null +++ b/internal/provider/antigravity_models.go @@ -0,0 +1,241 @@ +package provider + +// Antigravity lists some models once for each thinking level it serves +// them at ("gemini-3.7-flash-low", "-medium", "-high", named "Gemini 3.7 +// Flash (Low)" …), so a client that also picks an effort had two to pick. +// magpie offers such a family as one model ("gemini-3.7-flash", "Gemini +// 3.7 Flash") with the levels there are, and the gateway asks for the id +// the effort picks (AntigravityVariants). +// +// A family is only ids that are one id with an effort word after it: a +// model named like another but with an id of its own (gemini-3-flash-agent +// is "Gemini 3.5 Flash (High)") stays a model of its own, as does an id +// with no sibling. Which level a variant is at is what Antigravity's name +// for it says, where the names say one each: gemini-3.5-flash-extra-low is +// "(Low)" and gemini-3.5-flash-low "(Medium)"; else its id's word. + +import ( + "slices" + "strings" + + "github.com/yetone/magpie/internal/catalog" +) + +// antigravityEffortWords are the effort words of Antigravity's ids and the +// level each is, longest first. +var antigravityEffortWords = []struct{ word, level string }{ + {"extra-high", "xhigh"}, {"extra-low", "minimal"}, {"xhigh", "xhigh"}, {"minimal", "minimal"}, + {"low", "low"}, {"medium", "medium"}, {"high", "high"}, +} + +// splitAntigravityID is an id's family and the level its effort word +// says, "" when it has none. +func splitAntigravityID(id string) (family, effort string) { + for _, e := range antigravityEffortWords { + if b, ok := strings.CutSuffix(id, "-"+e.word); ok && b != "" { + return b, e.level + } + } + return id, "" +} + +// antigravityNameEffort is the level a name's last words in brackets say +// ("Gemini 3.7 Flash (High)" is high), and the name without them. +func antigravityNameEffort(name string) (string, string) { + open := strings.LastIndex(name, " (") + if open < 0 || !strings.HasSuffix(name, ")") { + return name, "" + } + var level string + switch strings.ToLower(name[open+2 : len(name)-1]) { + case "minimal", "extra low": + level = "minimal" + case "low": + level = "low" + case "medium": + level = "medium" + case "high": + level = "high" + case "extra high", "xhigh": + level = "xhigh" + default: + return name, "" + } + return strings.TrimSpace(name[:open]), level +} + +// antigravityFamily is one family of Antigravity's ids. +type antigravityFamily struct { + id string + variants []catalog.Model + efforts []string // each variant's level, "" for the id without one +} + +// byEffort is the id for each level, "" the one asked for when no level +// is: the family's id without an effort word, if Antigravity has it; else +// high — what Gemini 3 thinks at when it isn't told a level, so a client +// that asks for none gets what the plain model would give; else the +// highest there is. +func (f *antigravityFamily) byEffort() map[string]string { + out := map[string]string{} + for i, v := range f.variants { + if _, ok := out[f.efforts[i]]; !ok { + out[f.efforts[i]] = v.ID + } + } + if _, ok := out[""]; !ok { + out[""] = out["high"] + for i := len(cursorLevelRank) - 1; i >= 0 && out[""] == ""; i-- { + out[""] = out[cursorLevelRank[i]] + } + } + return out +} + +// model is the family as one model: named as Antigravity names its +// variants, without the level; with the levels there are, lowest first; +// holding what the least of its variants does. +func (f *antigravityFamily) model() catalog.Model { + def := f.byEffort()[""] + var m catalog.Model + for _, v := range f.variants { + if v.ID == def { + m = v + } + } + m.ID, m.Efforts = f.id, nil + m.Name, _ = antigravityNameEffort(m.Name) + if m.Name == "" || m.Name == def { + m.Name = f.id + } + for _, v := range f.variants { + if v.Context > 0 && (m.Context == 0 || v.Context < m.Context) { + m.Context = v.Context + } + if v.Output > 0 && (m.Output == 0 || v.Output < m.Output) { + m.Output = v.Output + } + m.Images = m.Images && v.Images + } + for _, l := range cursorLevelRank { + if slices.Contains(f.efforts, l) { + m.Efforts = append(m.Efforts, l) + } + } + return m +} + +// antigravityFamilies are the families of a list of Antigravity's ids, in +// the order the list first has them. +func antigravityFamilies(raw []catalog.Model) []*antigravityFamily { + var out []*antigravityFamily + by := map[string]*antigravityFamily{} + for _, m := range raw { + id, _ := splitAntigravityID(m.ID) + f := by[id] + if f == nil { + f = &antigravityFamily{id: id} + by[id] = f + out = append(out, f) + } + f.variants = append(f.variants, m) + } + for _, f := range out { + // the level each name says, if each says one and no two the same + var named []string + for _, v := range f.variants { + if _, l := antigravityNameEffort(v.Name); l != "" && !slices.Contains(named, l) { + named = append(named, l) + } + } + for _, v := range f.variants { + _, l := splitAntigravityID(v.ID) + if len(named) == len(f.variants) { + _, l = antigravityNameEffort(v.Name) + } + f.efforts = append(f.efforts, l) + } + } + return out +} + +// collapseAntigravityModels is Antigravity's list with each family one +// model; a family of one keeps Antigravity's id and name. +func collapseAntigravityModels(raw []catalog.Model) []catalog.Model { + var out []catalog.Model + for _, f := range antigravityFamilies(raw) { + if len(f.variants) == 1 { + out = append(out, f.variants[0]) + continue + } + out = append(out, f.model()) + } + return out +} + +// AntigravityVariants are Antigravity's ids a model magpie offers stands +// for, by level, "" the default; ok is false for a model that stands for +// none, which goes to Antigravity as it is. +func AntigravityVariants(model string) (map[string]string, bool) { + raw, _, ok := catalog.Live("antigravity") + if !ok { + return nil, false + } + return antigravityVariantsIn(raw, model) +} + +func antigravityVariantsIn(raw []catalog.Model, model string) (map[string]string, bool) { + for _, f := range antigravityFamilies(raw) { + if f.id == model && len(f.variants) > 1 { + return f.byEffort(), true + } + } + return nil, false +} + +// AntigravityBase is the model magpie offers for one of Antigravity's ids +// at a level ("gemini-3.7-flash-low" is gemini-3.7-flash at low), and that +// level; ok is false for an id that is itself a model magpie offers, or +// that no family has. Picks and agents' models saved before the families +// were one model still name these ids. +func AntigravityBase(id string) (base, effort string, ok bool) { + raw, _, live := catalog.Live("antigravity") + if !live { + return "", "", false + } + return antigravityBaseIn(raw, id) +} + +func antigravityBaseIn(raw []catalog.Model, id string) (string, string, bool) { + for _, f := range antigravityFamilies(raw) { + if len(f.variants) < 2 { + continue + } + for i, v := range f.variants { + if v.ID == id && id != f.id { + return f.id, f.efforts[i], true + } + } + } + return "", "", false +} + +// antigravityPicks are the user's picks of Antigravity's models with each +// of its ids at a level the model magpie offers for it, in the order +// picked, each once. +func antigravityPicks(ids []string) []string { + raw, _, live := catalog.Live("antigravity") + if !live { + return ids + } + var out []string + for _, id := range ids { + if base, _, ok := antigravityBaseIn(raw, id); ok { + id = base + } + if !slices.Contains(out, id) { + out = append(out, id) + } + } + return out +} diff --git a/internal/provider/antigravity_models_test.go b/internal/provider/antigravity_models_test.go new file mode 100644 index 00000000..47f6c27b --- /dev/null +++ b/internal/provider/antigravity_models_test.go @@ -0,0 +1,128 @@ +package provider + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// antigravityListed is what Antigravity's fetchAvailableModels said for +// an account (2026-09), as magpie keeps it. +var antigravityListed = []catalog.Model{ + {ID: "claude-opus-4-6-thinking", Name: "Claude Opus 4.6 (Thinking)", Images: true}, + {ID: "gemini-3-flash", Name: "Gemini 3 Flash", Images: true}, + {ID: "gemini-3-flash-agent", Name: "Gemini 3.5 Flash (High)", Images: true}, + {ID: "gemini-3.1-pro-high", Name: "Gemini 3.1 Pro (High)", Images: true}, + {ID: "gemini-3.1-pro-low", Name: "Gemini 3.1 Pro (Low)", Images: true}, + {ID: "gemini-3.5-flash-extra-low", Name: "Gemini 3.5 Flash (Low)", Images: true}, + {ID: "gemini-3.5-flash-lite", Name: "Gemini 3.5 Flash Lite", Images: true}, + {ID: "gemini-3.5-flash-low", Name: "Gemini 3.5 Flash (Medium)", Images: true}, + {ID: "gemini-3.7-flash-high", Name: "Gemini 3.7 Flash (High)", Images: true}, + {ID: "gemini-3.7-flash-low", Name: "Gemini 3.7 Flash (Low)", Images: true}, + {ID: "gemini-3.7-flash-medium", Name: "Gemini 3.7 Flash (Medium)", Images: true}, + {ID: "gemini-3.7-flash-tiered", Images: true}, + {ID: "gemini-pro-agent", Name: "Gemini 3.1 Pro (High)", Images: true}, + {ID: "gpt-oss-120b-medium", Name: "GPT-OSS 120B (Medium)"}, +} + +// Antigravity's ids of one model at several levels are one model with +// those levels; a model with one id, or only a name like another's, +// stays as Antigravity lists it. +func TestCollapseAntigravityModels(t *testing.T) { + var got []string + for _, m := range collapseAntigravityModels(antigravityListed) { + got = append(got, m.ID+"|"+m.Name+"|"+strings.Join(m.Efforts, ",")) + } + want := []string{ + "claude-opus-4-6-thinking|Claude Opus 4.6 (Thinking)|", + "gemini-3-flash|Gemini 3 Flash|", + "gemini-3-flash-agent|Gemini 3.5 Flash (High)|", + "gemini-3.1-pro|Gemini 3.1 Pro|low,high", + // the names say the levels: extra-low is Low, low is Medium + "gemini-3.5-flash|Gemini 3.5 Flash|low,medium", + "gemini-3.5-flash-lite|Gemini 3.5 Flash Lite|", + "gemini-3.7-flash|Gemini 3.7 Flash|low,medium,high", + "gemini-3.7-flash-tiered||", + "gemini-pro-agent|Gemini 3.1 Pro (High)|", + "gpt-oss-120b-medium|GPT-OSS 120B (Medium)|", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("got\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + + for model, want := range map[string]string{ + "gemini-3.7-flash": "=gemini-3.7-flash-high high=gemini-3.7-flash-high low=gemini-3.7-flash-low medium=gemini-3.7-flash-medium", + "gemini-3.5-flash": "=gemini-3.5-flash-low low=gemini-3.5-flash-extra-low medium=gemini-3.5-flash-low", + "gemini-3.1-pro": "=gemini-3.1-pro-high high=gemini-3.1-pro-high low=gemini-3.1-pro-low", + } { + vs, ok := antigravityVariantsIn(antigravityListed, model) + var got []string + for _, l := range []string{"", "high", "low", "medium"} { + if vs[l] != "" { + got = append(got, l+"="+vs[l]) + } + } + if !ok || strings.Join(got, " ") != want { + t.Errorf("%s: %v %v, want %s", model, got, ok, want) + } + } + for _, id := range []string{"gemini-3-flash", "gpt-oss-120b-medium", "gemini-3.7-flash-high", "gemini-pro-agent"} { + if _, ok := antigravityVariantsIn(antigravityListed, id); ok { + t.Errorf("%s has variants", id) + } + } + + // with an id of no level, it is the default + vs, _ := antigravityVariantsIn([]catalog.Model{{ID: "m-low"}, {ID: "m"}, {ID: "m-high"}}, "m") + if vs[""] != "m" || vs["low"] != "m-low" { + t.Errorf("variants %v", vs) + } + // without high, the highest there is + vs, _ = antigravityVariantsIn([]catalog.Model{{ID: "m-low"}, {ID: "m-medium"}}, "m") + if vs[""] != "m-medium" { + t.Errorf("variants %v", vs) + } +} + +// Picks saved before the levels were one model name Antigravity's own +// ids: each is the model magpie offers for it now, once, where it was; +// the rest stay. +func TestAntigravityLegacyPicks(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := catalog.SaveLive("antigravity", "", antigravityListed); err != nil { + t.Fatal(err) + } + for id, want := range map[string]string{"gemini-3.7-flash-high": "gemini-3.7-flash|high", "gemini-3.5-flash-extra-low": "gemini-3.5-flash|low", + "gemini-3.5-flash-low": "gemini-3.5-flash|medium", "gemini-3.1-pro-low": "gemini-3.1-pro|low"} { + if b, e, ok := AntigravityBase(id); !ok || b+"|"+e != want { + t.Errorf("%s: %s|%s %v, want %s", id, b, e, ok, want) + } + } + for _, id := range []string{"gemini-3.7-flash", "gpt-oss-120b-medium", "gemini-pro-agent", "gemini-3-flash-agent", "nope-low"} { + if b, _, ok := AntigravityBase(id); ok { + t.Errorf("%s taken for %s", id, b) + } + } + legacy := []string{"gemini-3.7-flash-high", "gemini-3-flash", "gemini-3.7-flash-low", "gemini-pro-agent", "gemini-3.5-flash-low", "gone-model"} + want := "gemini-3.7-flash,gemini-3-flash,gemini-pro-agent,gemini-3.5-flash,gone-model" + if got := strings.Join(antigravityPicks(legacy), ","); got != want { + t.Fatalf("picks %s, want %s", got, want) + } + + // what the account lists is the families, with their levels + p := Provider{ID: "antigravity"} + var got []string + for _, m := range p.Available() { + if strings.HasPrefix(m.ID, "gemini-3.7") { + got = append(got, m.ID+"|"+strings.Join(p.Efforts(m.ID), ",")) + } + } + if strings.Join(got, " ") != "gemini-3.7-flash|low,medium,high gemini-3.7-flash-tiered|" { + t.Fatalf("available %v", got) + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go index be498e2b..82985237 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -45,6 +45,10 @@ func (p Provider) Available() []catalog.Model { live = collapseCursorModels(withCursorContexts(live)) case "devin": live = withDevinContexts(live) + case "antigravity": + // after its names are filled in, and so that a family's + // levels aren't taken off by a known model of its id + return collapseAntigravityModels(catalog.Decorate(live, known)) } return catalog.Decorate(live, known) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index bb35938e..d00ce6b5 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -213,6 +213,9 @@ func All() []Provider { if a.ID == "cursor" { // picked before its efforts were one model a.Models = cursorPicks(a.Models) } + if a.ID == "antigravity" { // picked before its levels were one model + a.Models = antigravityPicks(a.Models) + } out = append(out, a) } return out diff --git a/main.go b/main.go index 53811bbb..a25caedc 100644 --- a/main.go +++ b/main.go @@ -105,6 +105,7 @@ func run(args []string) error { settings.Migrate() agent.RenameLegacy() agent.MoveCursorEfforts() + agent.MoveAntigravityEfforts() // a provider added, edited or removed, or a list fetched anew, reaches // the model lists agents keep in files of their own catalog.Changed = agent.SyncCatalog