mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
788e3b71fd
- 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
272 lines
7.1 KiB
Go
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
|
|
}
|