From 2a8b31531e08f65ab4b21fbce558ec988435ae0a Mon Sep 17 00:00:00 2001 From: Bruce MacDonald Date: Mon, 22 Jun 2026 15:38:19 -0700 Subject: [PATCH] launch/codex: detect model drift when Codex App UI switches away from Ollama (#16864) ollama launch codex-app sets root-level model_provider = "ollama-launch-codex-app" in ~/.codex/config.toml to route requests through the local Ollama server. In Codex, model_provider is a global config key, there is no per-model provider in the catalog schema (ModelInfo has no model_provider field), so it applies to every model, not just Ollama ones. When a user switches to a built-in OpenAI model (e.g. gpt-5.5) in the Codex App UI, the UI writes model = "gpt-5.5" to config.toml but does NOT update model_provider. The root model_provider stays "ollama-launch-codex-app", so the OpenAI model request goes to http://localhost:11434/v1/responses instead of OpenAI API, resulting in a 404 ("model gpt-5.5 not found"). The user is stuck: OpenAI models silently route to localhost until they know to run "ollama launch codex-app --restore". Fix: CurrentModel() now verifies the configured model appears as a slug in the Ollama-managed catalog before reporting the integration as active. When the model has drifted (user selected a non-Ollama model in the UI), CurrentModel() returns empty, so the launcher accurately shows the integration as inactive and the user is directed to restore or re-launch. --- cmd/launch/codex_app.go | 45 ++++++++++++++++++++++++++++-- cmd/launch/codex_app_test.go | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index f57e6b4f..6c3c5093 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -106,7 +106,10 @@ func (c *CodexApp) CurrentModel() string { if parsed.RootString(codexRootModelProviderKey) == profileName { baseURL := parsed.ProviderString(profileName, "base_url") if codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) && codexAppCatalogHealthy(parsed, profileName) { - return strings.TrimSpace(parsed.RootString(codexRootModelKey)) + model := strings.TrimSpace(parsed.RootString(codexRootModelKey)) + if codexAppCatalogContainsModel(model) { + return model + } } } } @@ -125,7 +128,11 @@ func (c *CodexApp) CurrentModel() string { if !codexAppCatalogHealthy(parsed, profileName) { return "" } - return strings.TrimSpace(parsed.ProfileString(profileName, codexRootModelKey)) + model := strings.TrimSpace(parsed.ProfileString(profileName, codexRootModelKey)) + if !codexAppCatalogContainsModel(model) { + return "" + } + return model } func codexAppManagedProfileNames() []string { @@ -169,6 +176,40 @@ func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool { return len(catalog.Models) > 0 } +// codexAppCatalogContainsModel reports whether model appears as a slug in the +// Ollama-managed model catalog. When the configured model is not in the catalog +// the user has drifted away from the launch-managed model (e.g. by selecting a +// built-in OpenAI model in the Codex App UI), and the launch config should be +// treated as inactive. +func codexAppCatalogContainsModel(model string) bool { + if strings.TrimSpace(model) == "" { + return false + } + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + return false + } + data, err := os.ReadFile(catalogPath) + if err != nil { + return false + } + var catalog struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + return false + } + target := codexAppCatalogModelKey(model) + for _, m := range catalog.Models { + if codexAppCatalogModelKey(m.Slug) == target { + return true + } + } + return false +} + func writeCodexAppConfig(configPath, model, modelCatalogPath string) error { baseURL := codexBaseURL() diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index 83b8472f..4693e176 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -626,6 +626,60 @@ func TestCodexAppCurrentModelRequiresHealthyCatalog(t *testing.T) { } } +func TestCodexAppCurrentModelDetectsDriftedModel(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + catalogPath := mustWriteCodexAppTestCatalog(t, "llama3.2") + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + content := "" + + `model = "gpt-5.5"` + "\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + + codexProviderHeaderFor(codexAppProfileName) + "\n" + + `name = "Ollama"` + "\n" + + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + + `wire_api = "responses"` + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if got := (&CodexApp{}).CurrentModel(); got != "" { + t.Fatalf("CurrentModel = %q, want empty when model has drifted from the Ollama catalog", got) + } +} + +func TestCodexAppCurrentModelAcceptsLatestSuffixDrift(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + catalogPath := mustWriteCodexAppTestCatalog(t, "llama3.2") + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + content := "" + + `model = "llama3.2:latest"` + "\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + + codexProviderHeaderFor(codexAppProfileName) + "\n" + + `name = "Ollama"` + "\n" + + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + + `wire_api = "responses"` + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if got := (&CodexApp{}).CurrentModel(); got != "llama3.2:latest" { + t.Fatalf("CurrentModel = %q, want llama3.2:latest (:latest suffix should not be treated as drift)", got) + } +} + func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir)