Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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"
Expand Down
96 changes: 95 additions & 1 deletion internal/tui/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
47 changes: 39 additions & 8 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading