Files
kit/internal/ui/model_selector.go
Ed Zynda 788e3b71fd feat(config): per-model baseUrl and apiKey for custom models
- Add `baseUrl` and `apiKey` fields to CustomModelConfig (config and models packages)
- Store them on ModelInfo so they travel through the registry
- createCustomProvider resolves URL/key from model definition first,
  falling back to global --provider-url / --provider-api-key
- Fix registry initialisation: call ReloadGlobalRegistry() in InitConfig()
  so customModels from config are visible on startup (not just at init time)
- Include custom provider in GetLLMProviders() so custom models appear
  in the /model selector
- Hide the built-in custom/custom stub from the selector when user-defined
  custom models are present
2026-04-03 12:37:14 +03:00

272 lines
7.1 KiB
Go

package ui
import (
"fmt"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/mark3labs/kit/internal/models"
)
// ModelEntry holds display metadata for a single model in the selector.
type ModelEntry struct {
Provider string
ModelID string
Name string // human-friendly name (e.g. "Claude Haiku 4.5")
ContextLimit int
Reasoning bool
}
// ModelSelectedMsg is sent when the user selects a model from the selector.
type ModelSelectedMsg struct {
ModelString string // "provider/model-id"
}
// ModelSelectorCancelledMsg is sent when the user cancels the selector.
type ModelSelectorCancelledMsg struct{}
// ModelSelectorComponent is a Bubble Tea component that displays a filterable
// list of available models as a centered overlay popup. It delegates rendering
// and keyboard navigation to PopupList and converts results into the
// ModelSelectedMsg / ModelSelectorCancelledMsg messages expected by AppModel.
type ModelSelectorComponent struct {
popup *PopupList
allModels []ModelEntry // kept for the custom filter callback
currentModel string // "provider/model" of the active model
width int
height int
active bool
}
// NewModelSelector creates a model selector populated from the global registry,
// filtered to only providers with configured API keys.
func NewModelSelector(currentModel string, width, height int) *ModelSelectorComponent {
registry := models.GetGlobalRegistry()
var allModels []ModelEntry
for _, providerID := range registry.GetLLMProviders() {
// Only include providers with valid API keys configured.
if err := registry.ValidateEnvironment(providerID, ""); err != nil {
continue
}
modelsMap, err := registry.GetModelsForProvider(providerID)
if err != nil {
continue
}
// For the custom provider, skip the built-in "custom" stub when
// user-defined models are present — the stub is a fallback for
// --provider-url usage and would just clutter the list.
userDefinedCustomModels := 0
if providerID == "custom" {
for modelID := range modelsMap {
if modelID != "custom" {
userDefinedCustomModels++
}
}
}
for modelID, info := range modelsMap {
if providerID == "custom" && modelID == "custom" && userDefinedCustomModels > 0 {
continue
}
allModels = append(allModels, ModelEntry{
Provider: providerID,
ModelID: modelID,
Name: info.Name,
ContextLimit: info.Limit.Context,
Reasoning: info.Reasoning,
})
}
}
// Sort: alphabetically by model ID, grouped by provider.
sort.Slice(allModels, func(i, j int) bool {
if allModels[i].Provider != allModels[j].Provider {
return allModels[i].Provider < allModels[j].Provider
}
return allModels[i].ModelID < allModels[j].ModelID
})
// Build PopupItems from model entries.
items := make([]PopupItem, len(allModels))
for i, m := range allModels {
items[i] = PopupItem{
Label: m.ModelID,
Description: fmt.Sprintf("[%s]", m.Provider),
Active: m.Provider+"/"+m.ModelID == currentModel,
Meta: m,
}
}
popup := NewPopupList("Model Selector", items, width, height)
popup.Subtitle = "Only showing models with configured API keys"
popup.FilterFunc = func(query string, allItems []PopupItem) []PopupItem {
return filterModels(query, allItems)
}
return &ModelSelectorComponent{
popup: popup,
allModels: allModels,
currentModel: currentModel,
width: width,
height: height,
active: true,
}
}
// Init implements tea.Model.
func (ms *ModelSelectorComponent) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
ms.width = msg.Width
ms.height = msg.Height
ms.popup.SetSize(msg.Width, msg.Height)
return ms, nil
case tea.KeyPressMsg:
result := ms.popup.HandleKey(msg.String(), msg.Text)
if result.Selected != nil {
ms.active = false
entry := result.Selected.Meta.(ModelEntry)
modelStr := entry.Provider + "/" + entry.ModelID
return ms, func() tea.Msg {
return ModelSelectedMsg{ModelString: modelStr}
}
}
if result.Cancelled {
ms.active = false
return ms, func() tea.Msg {
return ModelSelectorCancelledMsg{}
}
}
}
return ms, nil
}
// View implements tea.Model — not used for overlay rendering.
// Use RenderOverlay for the centered overlay approach.
func (ms *ModelSelectorComponent) View() tea.View {
// Fallback full-screen rendering (unused when rendered as overlay).
v := tea.NewView(ms.popup.RenderCentered(ms.width, ms.height))
v.AltScreen = true
return v
}
// RenderOverlay returns the popup as a centered overlay string, ready to be
// composited on top of the main content via overlayContent().
func (ms *ModelSelectorComponent) RenderOverlay(termWidth, termHeight int) string {
return ms.popup.RenderCentered(termWidth, termHeight)
}
// IsActive returns whether the selector is still accepting input.
func (ms *ModelSelectorComponent) IsActive() bool {
return ms.active
}
// --- Model-specific fuzzy filter ---
// filterModels scores and filters PopupItems whose Meta is a ModelEntry.
func filterModels(query string, items []PopupItem) []PopupItem {
if query == "" {
return items
}
q := strings.ToLower(query)
type scored struct {
item PopupItem
score int
}
var matches []scored
for _, item := range items {
entry, ok := item.Meta.(ModelEntry)
if !ok {
continue
}
s := fuzzyScoreModelEntry(q, entry)
if s > 0 {
matches = append(matches, scored{item: item, score: s})
}
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
a := matches[i].item.Meta.(ModelEntry)
b := matches[j].item.Meta.(ModelEntry)
return a.ModelID < b.ModelID
})
result := make([]PopupItem, len(matches))
for i, m := range matches {
result[i] = m.item
}
return result
}
// fuzzyScoreModelEntry scores a model entry against the search query.
func fuzzyScoreModelEntry(query string, entry ModelEntry) int {
modelID := strings.ToLower(entry.ModelID)
provider := strings.ToLower(entry.Provider)
name := strings.ToLower(entry.Name)
combined := provider + "/" + modelID
// Exact match on combined provider/model.
if combined == query {
return 1000
}
// Exact match on model ID.
if modelID == query {
return 950
}
// Prefix match on model ID.
if strings.HasPrefix(modelID, query) {
return 800 - len(modelID) + len(query)
}
// Prefix match on combined.
if strings.HasPrefix(combined, query) {
return 750 - len(combined) + len(query)
}
// Contains match on model ID.
if strings.Contains(modelID, query) {
return 600
}
// Contains match on combined.
if strings.Contains(combined, query) {
return 550
}
// Contains match on name.
if strings.Contains(name, query) {
return 400
}
// Character-by-character fuzzy match on model ID.
if s := fuzzyCharacterMatch(query, modelID); s > 0 {
return s
}
// Fuzzy match on combined.
if s := fuzzyCharacterMatch(query, combined); s > 0 {
return s - 20
}
return 0
}