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
124 changes: 118 additions & 6 deletions internal/web/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
Expand Down Expand Up @@ -226,14 +225,15 @@ 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
// runtime registry entry. Keep its explicitly configured image catalog
// 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
Expand All @@ -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",
})
}
Comment thread
cnjack marked this conversation as resolved.
imageProvider, imageModel := splitModelReference(cfg.ImageModel)

response := map[string]any{
Expand Down Expand Up @@ -691,13 +738,25 @@ 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)
if err != nil {
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

state, err := config.LoadModelState()
Expand All @@ -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
}
}
Expand All @@ -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,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
Expand Down
Loading
Loading