diff --git a/internal/models/models.go b/internal/models/models.go index 4ed05f0..8a77e7a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -83,9 +83,9 @@ var All = []Model{ Reasoning: true, }, { - ID: "mimo-v2.5", + ID: "mimo-v2.6-flash", Kind: Chat, - Name: "Xiaomi MiMo V2.5", + Name: "Xiaomi MiMo V2.6 Flash", Context: 1_048_576, Output: 32_768, Inputs: []string{InputText, InputImage, InputAudio}, @@ -141,6 +141,25 @@ func ChatModels() []Model { return out } +// Retired are ids the cluster served once and no longer does. Asking for one +// gets `401 This API key does not have access to the requested model`, which +// reads like a problem with the key and is not. The writers that merge into a +// config rather than rewrite it - opencode, droid, the Codex profiles - would +// otherwise keep offering them forever, so they take these out on the next +// setup run. +var Retired = []string{ + "mimo-v2.5", // replaced by mimo-v2.6-flash on 2026-10 +} + +func IsRetired(id string) bool { + for _, r := range Retired { + if r == id { + return true + } + } + return false +} + // Default is what a tool is left pointing at when it has no preference of its // own: the model the quickstart on nan.builders starts everyone with. const Default = "deepseek-v4-flash" diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index d2d7860..3ff5c76 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -160,6 +160,43 @@ func TestOpencodeConfigRepairsAnEntryWrittenByAnOlderVersion(t *testing.T) { } } +// mimo-v2.5 left the cluster for mimo-v2.6-flash. A merge that only ever adds +// kept it in the picker of everyone already configured, where choosing it +// answers 401 "does not have access to the requested model". +func TestOpencodeConfigTakesOutARetiredModel(t *testing.T) { + path := tempConfig(t, "opencode.json") + old := `{ + "provider": { + "nan": { + "npm": "@ai-sdk/openai-compatible", + "options": { "baseURL": "https://api.nan.builders/v1", "apiKey": "sk-old" }, + "models": { + "mimo-v2.5": { "name": "Xiaomi MiMo V2.5" }, + "gemma4": { "name": "my own name for it" } + } + } + } + }` + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + if err := writeOpencodeConfig(path, testKey); err != nil { + t.Fatal(err) + } + models := readJSON(t, path)["provider"].(map[string]any)["nan"].(map[string]any)["models"].(map[string]any) + for _, id := range catalog.Retired { + if _, ok := models[id]; ok { + t.Errorf("%s is still offered, and the cluster no longer serves it", id) + } + } + if _, ok := models["mimo-v2.6-flash"]; !ok { + t.Error("mimo-v2.6-flash was not added") + } + if models["gemma4"].(map[string]any)["name"] != "my own name for it" { + t.Error("taking out the retired model touched an entry the member owns") + } +} + func TestOpencodeConfigKeepsWhatItDoesNotOwn(t *testing.T) { path := tempConfig(t, "opencode.json") if err := os.WriteFile(path, []byte(`{"theme":"tokyonight","provider":{}}`), 0o600); err != nil { @@ -223,7 +260,7 @@ func TestPiConfigWritesOnlyTheModalitiesPiAccepts(t *testing.T) { } // Pi's schema is `("text" | "image")[]`. A third value fails validation and // Pi then refuses the whole file, every other provider in it included, so - // mimo-v2.5 goes in without its audio. + // mimo-v2.6-flash goes in without its audio. for id, entry := range piModels(t, path) { for _, raw := range entry["input"].([]any) { if in := raw.(string); in != "text" && in != "image" { @@ -551,6 +588,27 @@ func TestCodexProfilesGiveEveryModelItsOwnWindow(t *testing.T) { } } +func TestCodexProfilesOfARetiredModelGoAway(t *testing.T) { + path := tempConfig(t, "config.toml") + home := filepath.Dir(path) + stale := filepath.Join(home, "nan-mimo-v25.config.toml") + mine := filepath.Join(home, "work.config.toml") + for _, f := range []string{stale, mine} { + if err := os.WriteFile(f, []byte("model = \"x\"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := writeCodexConfig(path, testKey); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Error("nan-mimo-v25 is still there, and `--profile nan-mimo-v25` answers 401") + } + if _, err := os.Stat(mine); err != nil { + t.Errorf("a profile the member wrote was removed: %v", err) + } +} + // Codex refuses a --profile with a dot in it ("pass a plain name such as // `work`"), which is every id on the cluster that carries a version number. // With the sub-agent namespace or web_search in the request, /responses @@ -819,6 +877,42 @@ func TestFactoryConfigMarksWhatCannotSeeImages(t *testing.T) { } } +func TestFactoryConfigTakesOutARetiredModel(t *testing.T) { + path := tempConfig(t, "settings.json") + old := `{"customModels":[ + {"model":"glm5.3-flash","id":"custom:GLM-5.3-Flash-(NaN)-0","index":0,"baseUrl":"https://api.nan.builders/v1","displayName":"GLM 5.3 Flash (NaN)","provider":"openai"}, + {"model":"mimo-v2.5","id":"custom:Xiaomi-MiMo-V2.5-(NaN)-1","index":1,"baseUrl":"https://api.nan.builders/v1","displayName":"Xiaomi MiMo V2.5 (NaN)","provider":"openai"}, + {"model":"gpt-6.1-sol","id":"custom:mine-2","index":2,"baseUrl":"https://api.openai.com/v1","displayName":"mine","provider":"openai"} + ]}` + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + if err := writeFactoryConfig(path, testKey); err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + indexes := map[float64]string{} + for _, raw := range readJSON(t, path)["customModels"].([]any) { + entry := raw.(map[string]any) + id := entry["model"].(string) + seen[id] = true + i := entry["index"].(float64) + if other, dup := indexes[i]; dup { + t.Errorf("%s and %s share index %v", id, other, i) + } + indexes[i] = id + } + if seen["mimo-v2.5"] { + t.Error("mimo-v2.5 is still offered, and the cluster no longer serves it") + } + if !seen["mimo-v2.6-flash"] { + t.Error("mimo-v2.6-flash was not added") + } + if !seen["gpt-6.1-sol"] { + t.Error("a model the member added themselves was removed") + } +} + func TestEveryModelIsWrittenTheSameEverywhere(t *testing.T) { // The three writers used to keep their own list and all three disagreed. dir := t.TempDir() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4b40663..664a2ce 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2280,21 +2280,41 @@ func writeFactoryConfig(cfgPath, apiKey string) error { } } - // Index NaN model IDs already present + // Index NaN model IDs already present, dropping the ones the cluster no + // longer serves. existingIDs := map[string]bool{} + changed := false + kept := models[:0] + nextIndex := 0 for _, m := range models { if base, _ := m["baseUrl"].(string); strings.Contains(base, "api.nan.builders") { - if id, _ := m["model"].(string); id != "" { + id, _ := m["model"].(string) + if catalog.IsRetired(id) { + changed = true + continue + } + if id != "" { existingIDs[id] = true } } + // JSON numbers come back as float64. The next index goes past the + // highest one, not at len(models): with an entry taken out, len lands + // on an index someone already holds. + if i, ok := m["index"].(float64); ok && int(i) >= nextIndex { + nextIndex = int(i) + 1 + } + kept = append(kept, m) + } + models = kept + if len(models) > nextIndex { + nextIndex = len(models) } // Append only missing models - added := false for _, nm := range catalog.ChatModels() { if !existingIDs[nm.ID] { - idx := len(models) + idx := nextIndex + nextIndex++ display := nm.Name + " (NaN)" models = append(models, map[string]any{ "model": nm.ID, @@ -2306,10 +2326,10 @@ func writeFactoryConfig(cfgPath, apiKey string) error { "noImageSupport": !nm.Accepts(catalog.InputImage), "provider": "openai", }) - added = true + changed = true } } - if !added { + if !changed { return nil } cfg["customModels"] = models @@ -2376,6 +2396,12 @@ func writeOpencodeConfig(cfgPath, apiKey string) error { existing = map[string]any{} } changed := false + for id := range existing { + if catalog.IsRetired(id) { + delete(existing, id) + changed = true + } + } for id, m := range nanModels { found, ok := existing[id].(map[string]any) if !ok { @@ -2513,7 +2539,7 @@ func piSettingsPath(cfgPath string) string { return filepath.Join(filepath.Dir(cfgPath), "settings.json") } -// Pi's schema takes "text" and "image" and nothing else, so mimo-v2.5 goes in +// Pi's schema takes "text" and "image" and nothing else, so mimo-v2.6-flash goes in // without its audio: a third value fails validation, and Pi then refuses the // whole file with every other provider in it. func piInputs(m catalog.Model) []string { @@ -2681,6 +2707,11 @@ wire_api = "responses" // and leave with the disconnect - a web_search = "disabled" at the root // would stay behind and take search from the member's OpenAI sessions too. func writeCodexProfiles(codexHome string) error { + // Starting from none, so a model the cluster retired does not leave its + // profile behind to answer 401 on `--profile`. + if err := removeCodexProfiles(codexHome); err != nil { + return err + } for _, m := range catalog.ChatModels() { body := fmt.Sprintf(`# Written by the NaN CLI. Disconnecting Codex removes it. model = %q @@ -3610,7 +3641,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.29" +const Version = "0.1.30" func renderAbout(l layout) string { var b strings.Builder