From 157d29dc6ecd3d48be36a4017a81b3edfd31d862 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Tue, 13 Oct 2026 16:27:49 +0200 Subject: [PATCH] fix(models): mimo-v2.6-flash en lugar de mimo-v2.5, que el cluster ya no sirve (0.1.30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El cluster retiró mimo-v2.5 y sirve mimo-v2.6-flash. El catálogo seguía con la antigua, así que el CLI escribía en cada herramienta un modelo que contesta 401 "This API key does not have access to the requested model". En Codex, `--profile nan-mimo-v25` reintentaba cinco veces y acababa en ese 401, que parece un problema de la key y no lo es. Cambiar el id no bastaba para quien ya está conectado: opencode y droid solo añaden lo que falta, y los perfiles de Codex se escriben encima sin borrar nada. Hay una lista Retired en el catálogo y esos tres sacan los modelos retirados en la siguiente pasada del setup; Pi y Hermes ya reescribían el bloque entero. En droid el índice nuevo va detrás del más alto, porque con una entrada quitada len(models) cae en uno que ya está ocupado. Probado contra el cluster con Codex 0.162 y una copia de ~/.codex: el perfil nan-mimo-v25 desaparece, nan-mimo-v26-flash responde y llama a herramientas. Co-Authored-By: Claude Opus 5.5 --- internal/models/models.go | 23 ++++++++- internal/tui/config_test.go | 96 ++++++++++++++++++++++++++++++++++++- internal/tui/tui.go | 47 ++++++++++++++---- 3 files changed, 155 insertions(+), 11 deletions(-) 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