Files
zero/internal/tui/picker.go
6b9c2f0c08 feat(aimlapi): AI/ML API provider with guided onboarding (top-up + key issuance) (#655)
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance)

Add the AI/ML API provider integration to the zero CLI: a guided TUI
onboarding sub-flow that takes an existing key or an email top-up and
writes the issued key into the provider profile, attributed to the
Gitlawb rebate partner.

- internal/aimlapi: OpenAI-compatible client, partner-checkout top-up,
  key validation, streaming, and endpoint config (Paths A/B/C).
- providercatalog / providermodelcatalog: register the aimlapi provider
  with default headers and models.
- tui: aimlapi onboarding screen wired into the provider wizard and
  onboarding flow.

Default endpoints point at production (auth/app/api/pay/aimlapi.com);
each is overridable via AIMLAPI_*_URL env vars.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): address review findings in onboarding flow

- topup: use errors.As for the 5xx retry check so a wrapped APIError
  still retries instead of aborting a paid checkout.
- onboarding: a Path-A top-up on an email with no account now lands on a
  single coherent "key saved, balance not topped up" screen instead of a
  green "ready" plus a red error.
- onboarding: an empty amount field is rejected instead of silently
  falling through to the $25 default.
- collapse the two identical top-up prompt constants into MsgTopUpPrompt
  and drop the dead amountPrompt() branch.
- reuse stdlib maps.Clone instead of a third bespoke header-copy helper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): retry transient poll errors and sharpen amount validation

* fix(aimlapi): retry transient poll errors and surface real amount errors

* docs(aimlapi): document exported client API; drop out-of-scope gitignore entry

Addresses the two CodeRabbit PR checks on #655:

- Add doc comments to the exported aimlapi client API (Client, APIError,
  the session/checkout/onboarding types and their methods) and
  ParseAmountUSD, to satisfy the docstring-coverage check.
- Remove the unrelated /zero.exe~ .gitignore entry that an earlier
  review-fix commit added; it is out of scope for the provider work, so
  the PR no longer touches .gitignore.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): render aimlapi row like other recommended providers

* fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation)

* fix(aimlapi): validate pasted API keys

* test(aimlapi): clarify pasted key validation

* test(aimlapi): cover key-balance low-balance transition

* fix(aimlapi): reject non-finite top-up amounts

* fix(cli): scope and canonicalize provider headers

* fix(tui): distinguish invalid verification codes

* feat(aimlapi): resume account-bound top-ups safely

* feat(tui): offer top-up for existing AIMLAPI keys

* feat(tui): reuse AIMLAPI env key on first run

* feat(tui): reuse configured AIMLAPI profiles

* test(aimlapi): trim redundant flow coverage

* fix(tui): preserve existing AIMLAPI profile metadata

* fix(tui): clone AIMLAPI headers in env fallback

* fix(aimlapi): address remaining review findings

* fix(aimlapi): harden onboarding lifecycle

* fix(aimlapi): preserve validated checkout context

* fix(aimlapi): harden checkout retry state

* fix(tui): clarify AIMLAPI configured choices

* fix(aimlapi): make checkout retries idempotent

* fix(aimlapi): validate checkout responses

* fix(providers): reconcile aimlapi catalog after rebase

---------

Co-authored-by: Lookoff123 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-17 12:49:38 +08:00

1039 lines
37 KiB
Go

package tui
import (
"context"
"os"
"sort"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
// pickerKind identifies which command a picker selection feeds back into.
type pickerKind int
const (
pickerModel pickerKind = iota
pickerEffort
pickerSession
pickerTheme
pickerSkill
pickerSTTModel
pickerSTTDownload
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
// underlying command handler when chosen. Meta is the optional right-aligned
// readout (ctx window · capabilities); the dot flags mark provider locality
// for model rows (accent = remote, blue = local).
type pickerItem struct {
Group string
Label string
Value string
Meta string
Provider string // display tag (catalog id / locality)
// OwnerProvider is the saved provider profile name a model belongs to, so the
// /model picker can switch providers when a model from a non-active provider is
// chosen. Empty for non-model items.
OwnerProvider string
Remote bool
Local bool
Favorite bool
}
// commandPicker is a generic single-select overlay reused by /model and /effort
// (invoked with no argument). It owns only list state; the chosen
// value is applied through the existing command handlers.
type commandPicker struct {
kind pickerKind
title string
items []pickerItem
allItems []pickerItem
query string
selected int
// loading marks a picker still fetching its rows (e.g. the STT model list from
// GitHub): the overlay shows a "fetching…" line instead of "no matching items".
loading bool
}
func (p *commandPicker) move(delta int) {
n := len(p.items)
if n == 0 {
return
}
p.selected = ((p.selected+delta)%n + n) % n
}
func (p *commandPicker) current() (pickerItem, bool) {
if p.selected < 0 || p.selected >= len(p.items) {
return pickerItem{}, false
}
return p.items[p.selected], true
}
func (p *commandPicker) appendQuery(runes []rune) {
for _, r := range runes {
if r < 32 {
continue
}
p.query += string(r)
}
p.applyQuery()
}
func (p *commandPicker) deleteQueryRune() {
if p.query == "" {
return
}
runes := []rune(p.query)
p.query = string(runes[:len(runes)-1])
p.applyQuery()
}
func (p *commandPicker) applyQuery() {
source := p.allItems
if len(source) == 0 {
source = p.items
}
query := strings.ToLower(strings.TrimSpace(p.query))
if query == "" {
p.items = append([]pickerItem{}, source...)
p.selected = clampInt(p.selected, 0, maxInt(0, len(p.items)-1))
return
}
// Rank matches (exact < prefix < contains < subsequence) instead of a flat
// substring filter, so the closest match to "sonnet 4.5" lands at the top
// rather than buried behind scrolling. Groups stay contiguous — a group is
// ordered by its best-matching item and never split into two header blocks —
// so the grouped model picker still renders one header per provider.
type entry struct {
item pickerItem
score int
order int
}
groupFirst := map[string]int{}
groupBest := map[string]int{}
entries := make([]entry, 0, len(source))
for index, item := range source {
score, ok := scorePickerItem(item, query)
if !ok {
continue
}
if _, seen := groupFirst[item.Group]; !seen {
groupFirst[item.Group] = index
}
if best, seen := groupBest[item.Group]; !seen || score < best {
groupBest[item.Group] = score
}
entries = append(entries, entry{item: item, score: score, order: index})
}
sort.SliceStable(entries, func(a, b int) bool {
ga, gb := entries[a].item.Group, entries[b].item.Group
if ga != gb {
// Most-relevant group first; ties keep original group appearance order so
// a group is never scattered across the list.
if groupBest[ga] != groupBest[gb] {
return groupBest[ga] < groupBest[gb]
}
return groupFirst[ga] < groupFirst[gb]
}
// Within a group: best match first, then original order.
if entries[a].score != entries[b].score {
return entries[a].score < entries[b].score
}
return entries[a].order < entries[b].order
})
filtered := make([]pickerItem, 0, len(entries))
for _, e := range entries {
filtered = append(filtered, e.item)
}
p.items = filtered
p.selected = 0
}
// scorePickerItem ranks an item against a lowercased query; lower is better, and
// ok is false when it doesn't match at all. Tiers mirror scoreFileSuggestion:
// exact/prefix/contains on the label (what the user reads) beat matches deeper in
// the joined haystack, and a fuzzy subsequence is the last-resort match.
func scorePickerItem(item pickerItem, query string) (int, bool) {
label := strings.ToLower(item.Label)
hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta}, " "))
switch {
case label == query:
return 0, true
case strings.HasPrefix(label, query):
return 20, true
case strings.Contains(label, query):
return 40, true
case strings.HasPrefix(hay, query):
return 60, true
case strings.Contains(hay, query):
return 80, true
default:
if gap, ok := fuzzySubsequenceGap(hay, query); ok {
return 120 + gap, true
}
return 0, false
}
}
// newModelPicker lists active (non-deprecated) models, preselecting the active
// one. Returns nil when the catalog is unavailable so the caller falls back to
// the plain status text.
func (m model) newModelPicker() *commandPicker {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return nil
}
activeModel := strings.TrimSpace(m.modelName)
activeProvider := strings.TrimSpace(m.providerName)
recent := []pickerItem{}
for _, pair := range m.recentModelPairsForPicker() {
recent = append(recent, m.modelPickerRecentItem(registry, pair.Provider, pair.Model))
}
catalog := []pickerItem{}
// List every saved provider's models, grouped per provider (one contiguous
// section each), so /model shows all configured providers and you can switch
// across them. Dedup by group so two profiles resolving to the same provider
// don't produce a repeated section.
seenGroup := map[string]bool{}
for _, profile := range m.modelPickerProviders() {
descriptor, hasDescriptor := m.descriptorForProfile(profile)
group := modelPickerProviderGroup(profile, descriptor, hasDescriptor)
if seenGroup[group] {
continue
}
seenGroup[group] = true
catalog = append(catalog, m.savedProviderModelPickerItems(profile, activeProvider, activeModel)...)
}
if len(catalog) == 0 {
// No saved providers resolved any models: fall back to the full registry.
// Only skip the active model when the entry's own provider also matches the
// active provider — a different provider offering the same model id is a
// distinct, independently selectable row (same provider-aware de-dup this
// PR applies everywhere else), not a duplicate of the pinned "Recent" row.
for _, entry := range registry.List(modelregistry.ListOptions{}) {
if entry.ID == activeModel && (activeProvider == "" || strings.EqualFold(string(entry.Provider), activeProvider)) {
continue
}
catalog = append(catalog, registryModelPickerItem(entry, "Catalog"))
}
}
items := m.assembleModelPickerItems(recent, catalog)
if len(items) == 0 {
return nil
}
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
// the skill's EXACT name (item.Value), so even skills the slash path cannot
// reach — names shadowed by a builtin or user command, or names that don't fit
// a slash token — are runnable here: picking from the Skills picker is
// unambiguous. Returns nil when no skills are installed (the caller falls back
// to the install-hint card).
func (m model) newSkillPicker() *commandPicker {
installed := m.installedSkills()
items := make([]pickerItem, 0, len(installed))
for _, skill := range installed {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
label := name
if slash := skillSlashName(name); slash != "" {
// Show the slash form where one exists — it doubles as documentation
// of the direct "/name [args]" invocation.
label = "/" + slash
}
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
}
if len(items) == 0 {
return nil
}
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// modelPickerProviders returns the providers to list in /model: all saved
// providers, falling back to the active profile when none were threaded in.
func (m model) modelPickerProviders() []config.ProviderProfile {
if len(m.savedProviders) > 0 {
return m.savedProviders
}
if config.HasProviderProfile(m.providerProfile) {
return []config.ProviderProfile{m.providerProfile}
}
return nil
}
// savedProviderModelPickerItems lists one saved provider's models as a group,
// tagging each with the owning provider so selection can switch providers. The
// active provider prefers its live-discovered models when available.
func (m model) savedProviderModelPickerItems(profile config.ProviderProfile, activeProvider, activeModel string) []pickerItem {
providerName := strings.TrimSpace(profile.Name)
isActive := providerName != "" && strings.EqualFold(providerName, activeProvider)
descriptor, hasDescriptor := m.descriptorForProfile(profile)
group := modelPickerProviderGroup(profile, descriptor, hasDescriptor)
var raw []pickerItem
switch {
case hasDescriptor && len(m.modelPickerLiveByProvider[descriptor.ID]) > 0:
for _, model := range m.modelPickerLiveByProvider[descriptor.ID] {
if strings.TrimSpace(model.ID) == "" {
continue
}
raw = append(raw, discoveredModelPickerItem(descriptor, model, group))
}
case hasDescriptor && descriptor.Local:
// Local providers (e.g. Ollama) only have the models you've actually pulled.
// Never fall back to the static catalog — that would list models you don't
// have. Until live discovery returns them, this provider shows no rows.
case hasDescriptor:
for _, model := range providermodelcatalog.Models(descriptor) {
if strings.TrimSpace(model.ID) == "" {
continue
}
raw = append(raw, providerModelPickerItem(descriptor, model, group))
}
default:
// Custom provider without a catalog: surface its single configured model.
if mdl := strings.TrimSpace(profile.Model); mdl != "" {
raw = append(raw, pickerItem{Label: modelPickerDisplayName(mdl, ""), Value: mdl})
}
}
out := make([]pickerItem, 0, len(raw))
for _, item := range raw {
// The active model is already shown under "Recent" for the active provider.
if isActive && item.Value == activeModel {
continue
}
item.Group = group
item.OwnerProvider = providerName
out = append(out, item)
}
return out
}
// descriptorForProfile resolves a profile's catalog descriptor the same way the
// active provider is resolved — by CatalogID, then base URL, then a synthesized
// custom descriptor, then name/kind candidates — so every saved provider (not just
// the active one) lists its models.
func (m model) descriptorForProfile(profile config.ProviderProfile) (providercatalog.Descriptor, bool) {
if descriptor, ok := providercatalog.Get(profile.CatalogID); ok && !genericProviderCatalogID(descriptor.ID) {
return descriptor, true
}
if descriptor, ok := providerDescriptorByBaseURL(profile.BaseURL); ok {
return descriptor, true
}
if descriptor, ok := customProviderDescriptorForProfile(profile); ok {
return descriptor, true
}
for _, candidate := range []string{profile.Name, profile.Provider, string(profile.ProviderKind)} {
if descriptor, ok := providercatalog.Get(candidate); ok {
return descriptor, true
}
}
return providercatalog.Descriptor{}, false
}
func modelPickerProviderGroup(profile config.ProviderProfile, descriptor providercatalog.Descriptor, hasDescriptor bool) string {
if hasDescriptor && strings.TrimSpace(descriptor.Name) != "" {
return descriptor.Name
}
if name := strings.TrimSpace(profile.Name); name != "" {
return name
}
return "Provider"
}
func (m model) openModelPicker() (model, tea.Cmd) {
picker := m.newModelPicker()
if picker == nil {
return m, nil
}
m.picker = picker
m.clearModelPickerLoadState()
// Live-discover every usable provider's real models in the background. The list
// shows immediately from the static catalog and each provider's section refreshes
// as its discovery returns — no blocking overlay.
return m, m.modelPickerDiscoveryCmds()
}
// modelPickerDiscoveryCmds dispatches a live model-discovery command for each
// usable provider (deduped by catalog descriptor), so /model shows the same real
// models the provider-setup wizard discovers.
func (m model) modelPickerDiscoveryCmds() tea.Cmd {
cmds := []tea.Cmd{}
seen := map[string]bool{}
for _, profile := range m.modelPickerProviders() {
descriptor, ok := m.descriptorForProfile(profile)
if !ok || seen[descriptor.ID] {
continue
}
seen[descriptor.ID] = true
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, profile); cmd != nil {
cmds = append(cmds, cmd)
}
}
if len(cmds) == 0 {
return nil
}
return tea.Batch(cmds...)
}
// modelPickerProviderDiscoveryCmd discovers one provider's live models, resolving
// its credential the same way the wizard does: a stored/inline/env key, or a stored
// OAuth bearer for token-login providers (e.g. xAI).
func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descriptor, profile config.ProviderProfile) tea.Cmd {
authed := profile
if store, err := m.providerKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
key := strings.TrimSpace(authed.APIKey)
if key == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
key = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
}
needOAuth := key == "" && descriptor.OAuth && !descriptor.OAuthMintsKey
discover := m.discoverProviderModels
if discover == nil {
discover = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, providermodeldiscovery.Options{})
}
}
providerID := descriptor.ID
return func() tea.Msg {
ctx, cancel := context.WithTimeout(m.ctx, 8*time.Second)
defer cancel()
k := key
if needOAuth {
if resolved := oauthStoredToken(ctx, providerID); resolved != "" {
k = resolved
}
}
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k, authed.BaseURL))
return modelPickerModelsDiscoveredMsg{providerID: providerID, models: models, err: err}
}
}
func (m model) modelPickerIsLoading() bool {
return m.picker != nil && m.picker.kind == pickerModel && m.modelPickerLoading
}
func (m *model) clearModelPickerLoadState() {
m.modelPickerLoading = false
m.modelPickerLoadingProviderID = ""
m.modelPickerLoadError = ""
}
// pickerItemDedupKey identifies a picker row for cross-group de-duplication.
// Rows tagged with an owning provider are keyed by provider+model, not model
// id alone — the same model id can be offered by multiple providers (e.g. a
// model name available from both a direct provider and an OpenAI-compatible
// gateway), and those are distinct, independently selectable rows.
func pickerItemDedupKey(item pickerItem) string {
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" {
return strings.ToLower(owner) + "\x00" + item.Value
}
return item.Value
}
func (m model) assembleModelPickerItems(recent []pickerItem, catalog []pickerItem) []pickerItem {
result := []pickerItem{}
// Favorites keep the pre-provider-aware semantics: one row per favorited
// model ID, regardless of how many providers offer it. Track seen favorite
// model IDs by Value alone so a model favorited once doesn't surface twice
// just because it's in multiple provider catalogs.
favoriteSeen := map[string]bool{}
addFavorites := func(items []pickerItem) {
for _, item := range items {
if item.Value == "" || !m.favoriteModels[item.Value] || favoriteSeen[item.Value] {
continue
}
item.Group = "Favorites"
item.Favorite = true
result = append(result, item)
favoriteSeen[item.Value] = true
}
}
addFavorites(recent)
addFavorites(catalog)
// Recent and Catalog use the provider-aware de-dup key so the same model ID
// offered by different providers shows as distinct, independently selectable
// rows. They still skip any model ID already surfaced under Favorites, so a
// favorited model doesn't appear in a second group (preserving the prior
// "favorited models are hidden from the rest" behavior).
seen := map[string]bool{}
for _, item := range recent {
if item.Value == "" || favoriteSeen[item.Value] {
continue
}
key := pickerItemDedupKey(item)
if seen[key] {
continue
}
item.Group = "Recent"
item.Favorite = m.favoriteModels[item.Value]
result = append(result, item)
seen[key] = true
}
for _, item := range catalog {
if item.Value == "" || favoriteSeen[item.Value] {
continue
}
key := pickerItemDedupKey(item)
if seen[key] {
continue
}
item.Favorite = m.favoriteModels[item.Value]
result = append(result, item)
seen[key] = true
}
return result
}
// recentModelPairsForPicker returns the provider+model pairs to show under
// "Recent", newest first: the active provider/model is always pinned first
// (even before it has been recorded to history), followed by persisted
// history, de-duplicated by provider+model pair and capped to
// config.MaxRecentModels.
func (m model) recentModelPairsForPicker() []config.RecentModelEntry {
pairs := make([]config.RecentModelEntry, 0, len(m.recentModels)+1)
pairs = append(pairs, config.RecentModelEntry{Provider: m.providerName, Model: m.modelName})
pairs = append(pairs, m.recentModels...)
return config.NormalizeRecentModels(pairs)
}
// modelPickerRecentItem resolves one "Recent" row for a provider+model pair,
// tagging it with OwnerProvider so choosing it can switch providers (like any
// other cross-provider picker row) even when it isn't the active provider.
func (m model) modelPickerRecentItem(registry modelregistry.Registry, providerName, modelID string) pickerItem {
providerName = strings.TrimSpace(providerName)
if entry, ok := registry.Resolve(modelID); ok {
item := registryModelPickerItem(entry, "Recent")
item.Value = modelID
item.OwnerProvider = providerName
return item
}
if profile, ok := m.savedProviderByName(providerName); ok {
if descriptor, hasDescriptor := m.descriptorForProfile(profile); hasDescriptor {
for _, model := range providermodelcatalog.Models(descriptor) {
if model.ID == modelID {
item := providerModelPickerItem(descriptor, model, "Recent")
item.Value = modelID
item.OwnerProvider = profile.Name
return item
}
}
item := providerModelPickerItem(descriptor, providermodelcatalog.Model{ID: modelID}, "Recent")
item.Value = modelID
item.OwnerProvider = profile.Name
return item
}
}
return pickerItem{Group: "Recent", Label: modelPickerDisplayName(modelID, ""), Value: modelID, OwnerProvider: providerName}
}
func registryModelPickerItem(entry modelregistry.ModelEntry, group string) pickerItem {
item := pickerItem{
Group: group,
Label: firstProviderDisplayValue(entry.DisplayName, entry.ID),
Value: entry.ID,
// OwnerProvider defaults to the registry entry's canonical provider so
// cross-group de-dup (pickerItemDedupKey) can distinguish the same model
// id offered by different providers even in the plain-registry fallback
// path (no saved providers resolved any models). Callers that resolve a
// specific provider profile (e.g. modelPickerRecentItem) overwrite this
// with the profile name right after calling this constructor.
OwnerProvider: string(entry.Provider),
}
item.Meta = registryModelPickerMeta(entry)
if descriptor, ok := providercatalog.Get(string(entry.Provider)); ok {
applyProviderPickerMeta(&item, descriptor)
}
return item
}
func providerModelPickerItem(provider providercatalog.Descriptor, model providermodelcatalog.Model, group string) pickerItem {
item := pickerItem{
Group: group,
Label: modelPickerDisplayName(model.ID, model.Description),
Value: model.ID,
}
item.Meta = providerWizardModelMeta(model.ContextWindow, model.ToolCall, model.Reasoning, model.InputCost, model.OutputCost, model.Tags)
applyProviderPickerMeta(&item, provider)
return item
}
func discoveredModelPickerItem(provider providercatalog.Descriptor, model providermodeldiscovery.Model, group string) pickerItem {
item := pickerItem{
Group: group,
Label: modelPickerDisplayName(model.ID, model.Description),
Value: model.ID,
}
item.Meta = providerWizardModelMeta(model.ContextWindow, model.ToolCall, model.Reasoning, model.InputCost, model.OutputCost, model.Tags)
applyProviderPickerMeta(&item, provider)
return item
}
func applyProviderPickerMeta(item *pickerItem, provider providercatalog.Descriptor) {
item.Remote = !provider.Local
item.Local = provider.Local
item.Provider = providerPickerTag(provider)
}
// providerPickerTag is the short, lowercase provider slug shown right-aligned on
// each model row (e.g. "anthropic", "deepseek", "ollama"). Prefers the catalog
// ID since it is already the stable lowercase identifier; falls back to a
// lowercased display name for descriptors that only carry one.
func providerPickerTag(provider providercatalog.Descriptor) string {
if id := strings.TrimSpace(provider.ID); id != "" {
return id
}
return strings.ToLower(strings.TrimSpace(provider.Name))
}
func registryModelPickerMeta(entry modelregistry.ModelEntry) string {
parts := []string{}
if ctx := formatContextWindow(entry.ContextLimits.ContextWindow); ctx != "" {
parts = append(parts, ctx+" ctx")
}
if entry.Supports(modelregistry.ModelCapabilityToolCalling) {
parts = append(parts, "tools")
}
if entry.Supports(modelregistry.ModelCapabilityReasoning) {
parts = append(parts, "reasoning")
}
if entry.Supports(modelregistry.ModelCapabilityVision) {
parts = append(parts, "vision")
}
return strings.Join(parts, " · ")
}
func modelPickerDisplayName(id string, description string) string {
// The row title is the model NAME, not its marketing blurb. Catalog/discovery
// descriptions (from models.dev) are full sentences, and several models share
// the same one — using them as titles showed sentence-long rows that looked like
// exact duplicates and hid the actual id (which only survived in the meta line).
// Prefer the (prettified) id; fall back to a non-generic description only when
// there is no id to name the row.
id = strings.TrimSpace(id)
if id == "" {
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
return description
}
return "model"
}
name := id
if slash := strings.LastIndex(name, "/"); slash >= 0 && slash < len(name)-1 {
name = name[slash+1:]
}
name = strings.NewReplacer("-", " ", "_", " ", ":", " ").Replace(name)
words := strings.Fields(name)
for index, word := range words {
words[index] = modelPickerTitleWord(word)
}
return strings.Join(words, " ")
}
func modelPickerTitleWord(word string) string {
if word == "" {
return ""
}
lower := strings.ToLower(word)
switch lower {
case "api", "gpt", "glm", "vl":
return strings.ToUpper(lower)
default:
if strings.HasPrefix(lower, "gpt") || strings.HasPrefix(lower, "glm") {
return strings.ToUpper(lower[:3]) + word[3:]
}
return strings.ToUpper(word[:1]) + word[1:]
}
}
func (m model) activeProviderDescriptor() (providercatalog.Descriptor, bool) {
return m.descriptorForProfile(m.providerProfile)
}
func customProviderDescriptorForProfile(profile config.ProviderProfile) (providercatalog.Descriptor, bool) {
if descriptor, ok := providercatalog.Get(profile.CatalogID); ok && genericProviderCatalogID(descriptor.ID) {
return descriptor, true
}
baseURL := strings.TrimSpace(profile.BaseURL)
if baseURL == "" {
return providercatalog.Descriptor{}, false
}
switch profileProviderKind(profile) {
case config.ProviderKindOpenAICompatible:
if !sameNormalizedProviderBaseURL(baseURL, config.OpenAIBaseURL) {
return providercatalog.Get("custom-openai-compatible")
}
case config.ProviderKindAnthropicCompat:
if !sameNormalizedProviderBaseURL(baseURL, config.AnthropicBaseURL) {
return providercatalog.Get("custom-anthropic-compatible")
}
case config.ProviderKindOpenAI:
if !sameNormalizedProviderBaseURL(baseURL, config.OpenAIBaseURL) {
return providercatalog.Get("custom-openai-compatible")
}
case config.ProviderKindAnthropic:
if !sameNormalizedProviderBaseURL(baseURL, config.AnthropicBaseURL) {
return providercatalog.Get("custom-anthropic-compatible")
}
}
return providercatalog.Descriptor{}, false
}
func profileProviderKind(profile config.ProviderProfile) config.ProviderKind {
if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" {
return config.ProviderKind(strings.ToLower(kind))
}
if provider := strings.TrimSpace(profile.Provider); provider != "" {
return config.ProviderKind(strings.ToLower(provider))
}
return ""
}
func providerDescriptorByBaseURL(baseURL string) (providercatalog.Descriptor, bool) {
normalized := normalizeProviderBaseURL(baseURL)
if normalized == "" {
return providercatalog.Descriptor{}, false
}
for _, descriptor := range providercatalog.All() {
if genericProviderCatalogID(descriptor.ID) {
continue
}
if normalizeProviderBaseURL(descriptor.DefaultBaseURL) == normalized {
return descriptor, true
}
}
return providercatalog.Descriptor{}, false
}
func normalizeProviderBaseURL(baseURL string) string {
return strings.TrimRight(strings.ToLower(strings.TrimSpace(baseURL)), "/")
}
func sameNormalizedProviderBaseURL(left string, right string) bool {
return normalizeProviderBaseURL(left) == normalizeProviderBaseURL(right)
}
func genericProviderCatalogID(id string) bool {
return strings.HasPrefix(strings.TrimSpace(id), "custom-")
}
type modelPickerModelsDiscoveredMsg struct {
providerID string
models []providermodeldiscovery.Model
err error
}
// ollamaContextWindowDiscoveredMsg carries the result of an async /api/show
// probe against a local Ollama daemon (see ollamaContextWindowDiscoveryCmd).
// A zero contextWindow (or a non-nil err) means the probe found nothing
// usable and the map is left untouched, not zeroed out.
type ollamaContextWindowDiscoveredMsg struct {
modelName string
contextWindow int
err error
}
// ollamaContextWindowDiscoveryCmd probes a local Ollama daemon's native
// /api/show endpoint for modelName's context length. Scoped to the local
// Ollama provider only (catalog ID "ollama", not "ollama-cloud" — a
// different hosted service this endpoint isn't assumed to exist on): the
// generic /v1/models discovery has no source for this metadata for
// custom/local model tags, so without this the context gauge just never
// shows for them (see modelContextWindow).
func (m model) ollamaContextWindowDiscoveryCmd(descriptor providercatalog.Descriptor, baseURL string, modelName string) tea.Cmd {
if descriptor.ID != "ollama" {
return nil
}
modelName = strings.TrimSpace(modelName)
baseURL = strings.TrimSpace(baseURL)
if modelName == "" || baseURL == "" {
return nil
}
discover := m.discoverOllamaContextWindow
if discover == nil {
discover = func(ctx context.Context, baseURL string, model string) (int, error) {
return providermodeldiscovery.DiscoverOllamaContextWindow(ctx, baseURL, model, providermodeldiscovery.Options{})
}
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(m.ctx, 8*time.Second)
defer cancel()
window, err := discover(ctx, baseURL, modelName)
return ollamaContextWindowDiscoveredMsg{modelName: modelName, contextWindow: window, err: err}
}
}
func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor) config.ProviderProfile {
profile := m.providerProfile
normalizeIdentity := profileMatchesProviderBaseURL(profile, provider) ||
genericProviderCatalogID(profile.Name) ||
genericProviderCatalogID(profile.CatalogID) ||
strings.TrimSpace(profile.Name) == "" ||
strings.TrimSpace(profile.CatalogID) == ""
if normalizeIdentity {
// Only canonicalize an empty or generic placeholder name — never clobber a
// real user-provided profile name. The credential store is keyed by
// profile.Name (SecureProviderProfile stores under Name), so rewriting it
// here made the later profileWithCredential lookup miss the saved key and
// rebuild a keyless provider — a 401 on every /model switch for any profile
// named differently from its catalog id (e.g. "my-openai").
if strings.TrimSpace(profile.Name) == "" || genericProviderCatalogID(profile.Name) {
profile.Name = provider.ID
}
profile.CatalogID = provider.ID
}
if strings.TrimSpace(profile.BaseURL) == "" {
profile.BaseURL = provider.DefaultBaseURL
}
if strings.TrimSpace(string(profile.ProviderKind)) == "" {
profile.ProviderKind = providerWizardProviderKind(provider)
}
if strings.TrimSpace(profile.APIFormat) == "" {
profile.APIFormat = providerWizardAPIFormat(provider)
}
if len(provider.AuthEnvVars) > 0 && (strings.TrimSpace(profile.APIKeyEnv) == "" || normalizeIdentity) {
profile.APIKeyEnv = provider.AuthEnvVars[0]
}
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
}
return profile
}
func profileMatchesProviderBaseURL(profile config.ProviderProfile, provider providercatalog.Descriptor) bool {
baseURL := normalizeProviderBaseURL(profile.BaseURL)
return baseURL != "" && baseURL == normalizeProviderBaseURL(provider.DefaultBaseURL)
}
func (m model) applyModelPickerModelsDiscovered(msg modelPickerModelsDiscoveredMsg) model {
m.modelPickerLoading = false
m.modelPickerLoadingProviderID = ""
if msg.err != nil || len(msg.models) == 0 {
return m
}
if m.modelPickerLiveByProvider == nil {
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{}
}
m.modelPickerLiveByProvider[msg.providerID] = append([]providermodeldiscovery.Model{}, msg.models...)
// Rebuild the open picker so this provider's section shows its live models,
// preserving the current query + selection.
if m.picker != nil && m.picker.kind == pickerModel {
selectedValue := ""
query := m.picker.query
if item, ok := m.picker.current(); ok {
selectedValue = item.Value
}
m.picker = m.newModelPicker()
if m.picker != nil {
m.picker.query = query
m.picker.applyQuery()
m.selectPickerValue(selectedValue)
}
}
return m
}
func (m model) toggleModelFavorite() model {
if m.picker == nil || m.picker.kind != pickerModel {
return m
}
item, ok := m.picker.current()
if !ok || strings.TrimSpace(item.Value) == "" {
return m
}
if m.favoriteModels == nil {
m.favoriteModels = map[string]bool{}
}
if m.favoriteModels[item.Value] {
delete(m.favoriteModels, item.Value)
} else {
m.favoriteModels[item.Value] = true
}
if err := m.persistFavoriteModels(); err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "favorite save error: " + err.Error()})
}
selectedValue := item.Value
query := m.picker.query
m.picker = m.newModelPicker()
m.picker.query = query
m.picker.applyQuery()
m.selectPickerValue(selectedValue)
return m
}
func (m model) persistFavoriteModels() error {
if strings.TrimSpace(m.userConfigPath) == "" {
return nil
}
_, err := config.SetFavoriteModels(m.userConfigPath, favoriteModelValues(m.favoriteModels))
return err
}
// recordRecentModels prepends the given provider+model pairs (in order, so the
// last pair ends up frontmost) to the in-memory recent-selection history,
// de-duplicates by provider+model pair (moving a re-selected pair back to the
// front rather than leaving a stale older copy), caps the result, and persists
// it in a single normalize+write regardless of how many pairs are given. Model
// switches record both the outgoing and incoming pair for one logical switch —
// otherwise the model a session started on would silently drop out of "Recent"
// the moment you switch away from it once — and batching avoids two
// synchronous read-modify-write disk operations for a single user action.
// Persistence is skipped when there is no user config path, but the in-memory
// history is still updated so the picker reflects the selection for the rest
// of the session. Returns the receiver unchanged (no re-normalization, no
// write) when every pair has a blank model id.
func (m model) recordRecentModels(pairs ...config.RecentModelEntry) model {
entries := append([]config.RecentModelEntry{}, m.recentModels...)
changed := false
for _, pair := range pairs {
modelID := strings.TrimSpace(pair.Model)
if modelID == "" {
continue
}
changed = true
entries = append([]config.RecentModelEntry{{Provider: strings.TrimSpace(pair.Provider), Model: modelID}}, entries...)
}
if !changed {
return m
}
m.recentModels = normalizeRecentModelEntries(entries)
if path := strings.TrimSpace(m.userConfigPath); path != "" {
if _, err := config.SetRecentModels(path, m.recentModels); err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "recent model save error: " + err.Error()})
}
}
return m
}
// normalizeRecentModelEntries trims, drops entries with no model id,
// de-duplicates by provider+model pair (keeping the first/newest occurrence),
// and caps the result to config.MaxRecentModels. Delegates to
// config.NormalizeRecentModels so options loaded outside of config.Resolve
// (e.g. tests constructing Options directly) can never drift from the
// persisted-config normalization rules.
func normalizeRecentModelEntries(entries []config.RecentModelEntry) []config.RecentModelEntry {
return config.NormalizeRecentModels(entries)
}
func favoriteModelSet(models []string) map[string]bool {
if len(models) == 0 {
return nil
}
favorites := map[string]bool{}
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
favorites[model] = true
}
if len(favorites) == 0 {
return nil
}
return favorites
}
func favoriteModelValues(favorites map[string]bool) []string {
values := make([]string, 0, len(favorites))
for model, favorite := range favorites {
model = strings.TrimSpace(model)
if !favorite || model == "" {
continue
}
values = append(values, model)
}
sort.Strings(values)
return values
}
func (m *model) selectPickerValue(value string) {
if m.picker == nil || value == "" {
return
}
for index, item := range m.picker.items {
if item.Value == value {
m.picker.selected = index
return
}
}
}
// newEffortPicker lists the reasoning efforts the active model supports plus an
// "auto" option, preselecting the current preference. When the model exposes no
// effort controls, still returns a single "auto" picker so the user gets the
// popup affordance on /effort instead of a static status card; handleEffortCommand
// reports "Active model does not expose reasoning effort controls" if they pick
// anything other than auto.
func (m model) newEffortPicker() *commandPicker {
efforts := m.availableReasoningEfforts()
items := []pickerItem{{Label: "auto", Value: "auto"}}
selected := 0
if m.reasoningEffort == "" {
selected = 0
}
for _, effort := range efforts {
items = append(items, pickerItem{Label: string(effort), Value: string(effort)})
if m.reasoningEffort != "" && effort == m.reasoningEffort {
selected = len(items) - 1
}
}
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) {
if m.picker == nil {
return
}
m.picker.move(delta)
m.previewSelectedTheme()
}