diff --git a/internal/web/models.go b/internal/web/models.go index 2068101..020150f 100644 --- a/internal/web/models.go +++ b/internal/web/models.go @@ -163,7 +163,7 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { for _, imageModel := range providertools.ImageModels(cfg) { imageModelsByProvider[imageModel.Provider] = append(imageModelsByProvider[imageModel.Provider], imageModel) } - seenProviders := make(map[string]bool, len(configuredProviders)) + providerIndexes := make(map[string]int, len(configuredProviders)) for _, rp := range registry.ListProviders() { pc, configured := configuredProviders[rp.ID] if !configured { @@ -176,7 +176,6 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { if len(models) == 0 && len(imageModelsByProvider[rp.ID]) == 0 { continue } - seenProviders[rp.ID] = true pi := providerInfo{ ID: rp.ID, Name: rp.Name, Kind: rp.ID, Source: "desktop", Custom: rp.Custom, } @@ -226,6 +225,7 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { }) } } + providerIndexes[rp.ID] = len(result) result = append(result, pi) } // An image-only custom provider may have no chat models and therefore no @@ -233,7 +233,7 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { // visible without teaching the chat registry to route those models. for providerID, pc := range configuredProviders { imageModels := imageModelsByProvider[providerID] - if seenProviders[providerID] || pc == nil || len(imageModels) == 0 { + if _, seen := providerIndexes[providerID]; seen || pc == nil || len(imageModels) == 0 { continue } name := pc.Name @@ -251,8 +251,55 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { ImageResolutions: append([]string(nil), imageModel.Resolutions...), }) } + providerIndexes[providerID] = len(result) result = append(result, pi) } + // Older clients recorded live models discovered from an API-key custom + // provider only in model_state.json. Those explicit refs are valid runtime + // routes (the provider supplies the endpoint and credentials), but they do not + // exist in the config-backed registry and would therefore disappear from the + // picker after a refresh. Project them as conservative text/tool models. New + // enables are also persisted into CustomModels by handleToggleModelEnabled; + // this fallback keeps existing installations working without a migration that + // mutates config during a GET. Keep it limited to legacy API-key profiles with + // no declared chat or image models: once config has an authored catalog, it is + // authoritative and removals from the provider editor must stay removed. + for _, ref := range modelState.EnabledModels { + pc, configured := configuredProviders[ref.Provider] + if !configured || pc == nil || ref.Provider == "" || ref.Model == "" || + pc.Auth != nil || strings.TrimSpace(pc.BaseURL) == "" || pc.HasConfiguredChatModels() || + pc.ImageEndpoint != nil || !modelState.IsModelEnabled(ref, false) || + !providerIsCustom(registry, ref.Provider) { + continue + } + providerIndex, exists := providerIndexes[ref.Provider] + if !exists { + name := pc.Name + if name == "" { + name = ref.Provider + } + providerIndex = len(result) + providerIndexes[ref.Provider] = providerIndex + result = append(result, providerInfo{ + ID: ref.Provider, Name: name, Kind: ref.Provider, Source: "desktop", Custom: true, + }) + } + alreadyListed := false + for _, candidate := range result[providerIndex].Models { + if candidate.ID == ref.Model { + alreadyListed = true + break + } + } + if alreadyListed { + continue + } + result[providerIndex].Models = append(result[providerIndex].Models, modelInfo{ + ID: ref.Model, Name: ref.Model, ToolCall: true, Enabled: true, + InputModalities: []string{"text"}, OutputModalities: []string{"text"}, + CapabilityAvailability: "unsupported", + }) + } imageProvider, imageModel := splitModelReference(cfg.ImageModel) response := map[string]any{ @@ -691,6 +738,7 @@ func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request return } managedConfigChanged := false + customConfigChanged := false if req.Enabled { var err error managedConfigChanged, err = s.ensureManagedModelConfigured(r.Context(), req.Provider, req.Model) @@ -698,6 +746,17 @@ func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } + if !managedConfigChanged { + customConfigChanged, err = s.ensureCustomModelConfigured(req.Provider, req.Model) + if err != nil { + config.Logger().Printf( + "[models] custom model persistence failed provider=%q model=%q: %v", + req.Provider, req.Model, err, + ) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save custom model"}) + return + } + } } state, err := config.LoadModelState() @@ -709,9 +768,9 @@ func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) return } - if managedConfigChanged { - if err := s.rebuildProviderDependents(req.Provider, "enable managed model"); err != nil { - writeSavedButNotApplied(w, "managed provider model") + if managedConfigChanged || customConfigChanged { + if err := s.rebuildProviderDependents(req.Provider, "enable discovered model"); err != nil { + writeSavedButNotApplied(w, "provider model") return } } @@ -722,6 +781,59 @@ func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request }) } +// ensureCustomModelConfigured persists a model selected from a custom +// API-key provider's live /models catalog. Visibility state alone cannot teach +// the config-backed registry about a previously unknown model, so without this +// row the switch appears to save but the chat picker has nothing to render. +func (s *Server) ensureCustomModelConfigured(providerID, modelID string) (bool, error) { + cfg, err := config.LoadConfig() + if err != nil { + return false, fmt.Errorf("load config for custom provider %q: %w", providerID, err) + } + provider := cfg.GetProviders()[providerID] + if provider == nil || provider.Auth != nil || strings.TrimSpace(provider.BaseURL) == "" || + !providerIsCustom(model.NewModelRegistryWithConfig(cfg), providerID) { + return false, nil + } + for _, existing := range provider.CustomModels { + if existing.ID == modelID { + return false, nil + } + } + + configChanged := false + s.cfgMu.Lock() + configLocked := true + defer func() { + if configLocked { + s.cfgMu.Unlock() + } + }() + latest, err := config.MutateConfig(func(current *config.Config) error { + pc := current.GetProviders()[providerID] + if pc == nil || pc.Auth != nil || strings.TrimSpace(pc.BaseURL) == "" { + return errors.New("custom provider configuration changed while enabling model") + } + for _, existing := range pc.CustomModels { + if existing.ID == modelID { + return nil + } + } + pc.CustomModels = append(pc.CustomModels, config.CustomModelConfig{ + ID: modelID, Name: modelID, ToolCall: true, + }) + configChanged = true + return nil + }) + if err != nil { + return false, fmt.Errorf("persist custom model %q for provider %q: %w", modelID, providerID, err) + } + s.publishConfigSnapshotLocked(latest) + s.cfgMu.Unlock() + configLocked = false + return configChanged, nil +} + func (s *Server) ensureManagedModelConfigured( ctx context.Context, providerID string, diff --git a/internal/web/models_test.go b/internal/web/models_test.go index 2a90c37..9ad814a 100644 --- a/internal/web/models_test.go +++ b/internal/web/models_test.go @@ -99,6 +99,51 @@ func TestProviderCatalogUsesPersistedModelVisibility(t *testing.T) { } } +func TestCustomProviderCatalogUsesPersistedLiveModelVisibility(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": []map[string]string{{"id": "Qwen3.8-27B-MLX-8bit"}}, + }) + })) + defer live.Close() + + const providerID = "Local" + if err := config.SaveConfig(&config.Config{Providers: map[string]*config.ProviderConfig{ + providerID: {APIKey: "test", BaseURL: live.URL + "/v1", Name: providerID}, + }}); err != nil { + t.Fatal(err) + } + if err := config.SaveModelState(&config.ModelState{EnabledModels: []config.ModelRef{{ + Provider: providerID, Model: "Qwen3.8-27B-MLX-8bit", + }}}); err != nil { + t.Fatal(err) + } + + s := &Server{registry: model.NewModelRegistry()} + req := httptest.NewRequest(http.MethodGet, "/api/providers/Local/models", nil) + req.SetPathValue("id", providerID) + rec := httptest.NewRecorder() + s.handleProviderCatalog(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("catalog: code=%d body=%q", rec.Code, rec.Body.String()) + } + var got []struct { + ID string `json:"id"` + Added bool `json:"added"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "Qwen3.8-27B-MLX-8bit" || !got[0].Added { + t.Fatalf("live custom catalog lost persisted visibility: %#v", got) + } +} + func TestManagedProviderCatalogUsesLiveAccountModels(t *testing.T) { t.Setenv("HOME", t.TempDir()) err := config.SaveConfig(&config.Config{ @@ -188,6 +233,49 @@ func TestEnableManagedModelPersistsRuntimeMetadata(t *testing.T) { } } +func TestEnableCustomProviderLiveModelPersistsRuntimeMetadata(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfg := &config.Config{Providers: map[string]*config.ProviderConfig{ + "Local": {APIKey: "test", BaseURL: "http://127.0.0.1:1234/v1", Name: "Local"}, + }} + if err := config.SaveConfig(cfg); err != nil { + t.Fatal(err) + } + s := &Server{ + cfg: &config.Config{}, + registry: model.NewModelRegistry(), + needsSetup: true, + } + recorder := httptest.NewRecorder() + s.handleToggleModelEnabled(recorder, httptest.NewRequest( + http.MethodPost, "/api/model-state/enabled", + strings.NewReader(`{"provider":"Local","model":"Qwen3.8-27B-MLX-8bit","enabled":true}`), + )) + if recorder.Code != http.StatusOK { + t.Fatalf("enable model: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + loaded, err := config.LoadConfig() + if err != nil { + t.Fatal(err) + } + models := loaded.Providers["Local"].CustomModels + if len(models) != 1 || models[0].ID != "Qwen3.8-27B-MLX-8bit" || + models[0].Name != "Qwen3.8-27B-MLX-8bit" || !models[0].ToolCall || models[0].Managed { + t.Fatalf("stored custom live model = %#v", models) + } + if _, _, ok := s.registry.LookupModel("Local", "Qwen3.8-27B-MLX-8bit"); !ok { + t.Fatal("live registry was not rebuilt with enabled custom model") + } + state, err := config.LoadModelState() + if err != nil { + t.Fatal(err) + } + if !state.IsModelEnabled(config.ModelRef{Provider: "Local", Model: "Qwen3.8-27B-MLX-8bit"}, false) { + t.Fatal("enabled custom model was not persisted in model state") + } +} + func TestManagedModelConfigFromLiveUsesXAIImagePrice(t *testing.T) { got := managedModelConfigFromLive(model.NewModelRegistry(), "xai", providerauth.Model{ ID: "grok-4.6", Name: "grok-4.6", Vendor: "xai", @@ -386,6 +474,131 @@ func TestListModelsExposesModalitiesAndExplicitImageCatalog(t *testing.T) { } } +func TestListModelsProjectsPersistedCustomLiveModel(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const ( + providerID = "Local" + modelID = "Qwen3.8-27B-MLX-8bit" + ) + if err := config.SaveModelState(&config.ModelState{EnabledModels: []config.ModelRef{{ + Provider: providerID, Model: modelID, + }}}); err != nil { + t.Fatal(err) + } + cfg := &config.Config{Providers: map[string]*config.ProviderConfig{ + providerID: {APIKey: "test", BaseURL: "http://127.0.0.1:1234/v1", Name: providerID}, + }} + s := &Server{cfg: cfg} + rec := httptest.NewRecorder() + s.handleListModels(rec, httptest.NewRequest(http.MethodGet, "/api/models", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Providers []struct { + ID string `json:"id"` + Custom bool `json:"custom"` + Models []struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + ToolCall bool `json:"tool_call"` + InputModalities []string `json:"input_modalities"` + OutputModalities []string `json:"output_modalities"` + } `json:"models"` + } `json:"providers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + for _, provider := range response.Providers { + if provider.ID != providerID { + continue + } + if !provider.Custom || len(provider.Models) != 1 { + t.Fatalf("projected custom provider = %#v", provider) + } + got := provider.Models[0] + if got.ID != modelID || !got.Enabled || !got.ToolCall || + !hasModality(got.InputModalities, "text") || !hasModality(got.OutputModalities, "text") { + t.Fatalf("projected live model = %#v", got) + } + return + } + t.Fatalf("custom provider missing from /api/models: %s", rec.Body.String()) +} + +func TestProviderUpdateDoesNotProjectRemovedCustomModel(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const ( + providerID = "Local" + removedID = "removed-model" + survivorID = "survivor-model" + ) + cfg := &config.Config{ + Model: providerID + "/" + survivorID, + Providers: map[string]*config.ProviderConfig{ + providerID: { + APIKey: "test", BaseURL: "http://127.0.0.1:1234/v1", Name: providerID, + CustomModels: []config.CustomModelConfig{ + {ID: removedID, ToolCall: true}, + {ID: survivorID, ToolCall: true}, + }, + }, + }, + } + if err := config.SaveConfig(cfg); err != nil { + t.Fatal(err) + } + if err := config.SaveModelState(&config.ModelState{EnabledModels: []config.ModelRef{{ + Provider: providerID, Model: removedID, + }}}); err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, registry: model.NewModelRegistryWithConfig(cfg), needsSetup: true} + updateReq := httptest.NewRequest( + http.MethodPut, "/api/providers/Local", + strings.NewReader(`{"custom_models":[{"id":"survivor-model"}]}`), + ) + updateReq.SetPathValue("id", providerID) + updateRec := httptest.NewRecorder() + s.handleUpdateProvider(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("provider update: status=%d body=%s", updateRec.Code, updateRec.Body.String()) + } + + modelsRec := httptest.NewRecorder() + s.handleListModels(modelsRec, httptest.NewRequest(http.MethodGet, "/api/models", nil)) + if modelsRec.Code != http.StatusOK { + t.Fatalf("list models: status=%d body=%s", modelsRec.Code, modelsRec.Body.String()) + } + var response struct { + Providers []struct { + ID string `json:"id"` + Models []struct { + ID string `json:"id"` + } `json:"models"` + } `json:"providers"` + } + if err := json.Unmarshal(modelsRec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + foundSurvivor := false + for _, provider := range response.Providers { + if provider.ID != providerID { + continue + } + for _, candidate := range provider.Models { + if candidate.ID == removedID { + t.Fatalf("removed custom model was projected again: %s", modelsRec.Body.String()) + } + foundSurvivor = foundSurvivor || candidate.ID == survivorID + } + } + if !foundSurvivor { + t.Fatalf("surviving custom model missing: %s", modelsRec.Body.String()) + } +} + func TestListModelsExposesManagedXAIImageRole(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/web/providers.go b/internal/web/providers.go index e13fd90..6770d1e 100644 --- a/internal/web/providers.go +++ b/internal/web/providers.go @@ -483,7 +483,12 @@ func (s *Server) handleProviderCatalog(w http.ResponseWriter, r *http.Request) { if c := customSet[id]; c != nil { result = append(result, customEntry(id)) } else { - result = append(result, catalogEntry{ID: id, Added: configured[id]}) + result = append(result, catalogEntry{ + ID: id, + Added: modelState.IsModelEnabled( + config.ModelRef{Provider: providerID, Model: id}, configured[id], + ), + }) } } writeJSON(w, http.StatusOK, result)