okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: login on listFrame/listView, removal through the shared confirm on ctrl+d479178e

main
7.7 KiB260 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/config"
5	"albedo/cli/internal/daemon"
6	"cmp"
7	"context"
8	"slices"
9
10	tea "charm.land/bubbletea/v2"
11)
12
13type customProvider struct {
14	ID            string
15	Label         string
16	Detail        string
17	Extension     string
18	DefaultName   string
19	DefaultURL    string
20	FixedProtocol string
21	// FixedEndpoint skips the base url: the extension knows where it sends.
22	FixedEndpoint bool
23	// Scoped asks for a cloud project and location instead of a base url and
24	// api key: the extension signs in with credentials the daemon host holds.
25	Scoped bool
26	// Optional lets the base url and api key stay blank, so the extension
27	// falls back to the daemon host's own environment.
28	Optional bool
29}
30
31var customProviders = []customProvider{
32	{
33		ID:            "add-alibaba",
34		Label:         "add or update an Alibaba provider",
35		Detail:        "token plan · Qwen, DeepSeek, GLM",
36		Extension:     "alibaba",
37		DefaultName:   "alibaba",
38		DefaultURL:    "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
39		FixedProtocol: "chat_completions",
40	},
41	{
42		ID:            "add-anthropic",
43		Label:         "add or update an Anthropic API key",
44		Detail:        "console billing · Claude models",
45		Extension:     "claude",
46		DefaultName:   "anthropic",
47		FixedProtocol: "chat_completions",
48		FixedEndpoint: true,
49	},
50	{
51		ID:            "add-bedrock",
52		Label:         "add or update an Amazon Bedrock provider",
53		Detail:        "aws credentials · Claude models",
54		Extension:     "bedrock",
55		DefaultName:   "bedrock",
56		FixedProtocol: "chat_completions",
57		Optional:      true,
58	},
59	{
60		ID:            "add-vertex",
61		Label:         "add or update a Google Vertex AI provider",
62		Detail:        "application default credentials · Gemini models",
63		Extension:     "vertex",
64		DefaultName:   "vertex",
65		FixedProtocol: "chat_completions",
66		Scoped:        true,
67	},
68	{
69		ID:         "add-openai",
70		Label:      "add or update an OpenAI-compatible provider",
71		Extension:  "openai",
72		DefaultURL: "https://api.openai.com/v1",
73	},
74}
75
76func customProviderByID(id string) (customProvider, bool) {
77	for _, p := range customProviders {
78		if p.ID == id {
79			return p, true
80		}
81	}
82	return customProvider{}, false
83}
84
85// custom finds the api-key provider the draft's extension belongs to.
86func (m LoginModel) custom() (customProvider, bool) {
87	i := slices.IndexFunc(customProviders, func(p customProvider) bool { return p.Extension == m.Draft.Extension })
88	if i < 0 {
89		return customProvider{}, false
90	}
91	return customProviders[i], true
92}
93
94func (m LoginModel) optionalKey() bool {
95	p, _ := m.custom()
96	return p.Optional
97}
98
99func (m LoginModel) isFixedProtocol() bool {
100	p, ok := m.custom()
101	return ok && p.FixedProtocol != ""
102}
103
104// askEndpoint asks for the base url, or straight for the api key when the
105// extension has a fixed endpoint.
106func (m *LoginModel) askEndpoint() tea.Cmd {
107	p, _ := m.custom()
108	switch {
109	case p.Scoped:
110		return m.askProject()
111	case p.FixedEndpoint:
112		return m.askAPIKey()
113	}
114	m.Step = StepBaseURL
115	m.TextInput.Placeholder = ""
116	if p.Optional {
117		m.TextInput.Placeholder = "blank: your AWS region"
118	}
119	return m.promptInput(m.Draft.BaseURL, false)
120}
121
122func (m *LoginModel) askAPIKey() tea.Cmd {
123	m.Step = StepAPIKey
124	m.TextInput.Placeholder = ""
125	if p, _ := m.custom(); p.Optional {
126		m.TextInput.Placeholder = "blank: your AWS credentials"
127	}
128	return m.promptInput("", true)
129}
130
131func (m *LoginModel) askProject() tea.Cmd {
132	m.Step = StepProject
133	m.TextInput.Placeholder = "blank: GOOGLE_VERTEX_PROJECT"
134	return m.promptInput(deref(m.Draft.Project), false)
135}
136
137func (m *LoginModel) askLocation() tea.Cmd {
138	m.Step = StepLocation
139	m.TextInput.Placeholder = "blank: GOOGLE_VERTEX_LOCATION"
140	return m.promptInput(deref(m.Draft.Location), false)
141}
142
143// optionalText is the pointer a profile stores for a field left blank or set.
144func optionalText(value string) *string {
145	if value == "" {
146		return nil
147	}
148	return &value
149}
150
151func deref(value *string) string {
152	if value == nil {
153		return ""
154	}
155	return *value
156}
157
158func (m *LoginModel) advanceToModels() tea.Cmd {
159	m.Catalog = nil
160	m.CatalogNote = ""
161	m.Step = StepModels
162	m.Generation = nextPageGeneration()
163	return m.fetchCatalogCmd(m.Draft.Extension, m.Draft.BaseURL, m.Generation)
164}
165
166func (m *LoginModel) startCustomProvider(p customProvider) tea.Cmd {
167	m.Name = p.DefaultName
168	m.Draft = config.Settings{Extension: p.Extension, BaseURL: p.DefaultURL, Protocol: cmp.Or(p.FixedProtocol, "responses")}
169	m.Step = StepName
170	m.TextInput.Placeholder = p.DefaultName
171	return m.promptInput("", false)
172}
173
174// useProfile re-selects a saved profile: a sign-in provider with no accounts
175// left cannot run without an api key, so it signs in first.
176func (m *LoginModel) useProfile(name string, settings config.Settings) tea.Cmd {
177	if login, ok := m.signInFor(settings.Extension); ok && !hasKey(settings) && m.signedOut(settings.Extension) {
178		return m.startSignIn(login)
179	}
180	m.Step = StepSaving
181	return m.saveProviderCmd(name, settings)
182}
183
184// applyHint routes a name given to `/login <name>`: a saved profile is chosen as
185// it stands, a sign-in provider starts its sign-in, anything else is a new
186// openai-compatible provider.
187func (m *LoginModel) applyHint() tea.Cmd {
188	hint := m.Hint
189	m.Hint = ""
190	name, err := config.ValidateProviderName(hint)
191	if err != nil {
192		m.Error = err.Error()
193		m.Step = StepChoose
194		return m.ChoosePicker.Init()
195	}
196	m.Name = name
197	if saved, ok := m.Profiles.Providers[name]; ok {
198		m.Draft = saved
199		return m.useProfile(name, saved)
200	}
201	if login, ok := m.signInFor(name); ok {
202		return m.startSignIn(login)
203	}
204	return m.askEndpoint()
205}
206
207func (m *LoginModel) buildProtocolPicker() {
208	items := []PickerItem{
209		{ID: "responses", Label: "responses", Detail: "OpenAI Responses API"},
210		{ID: "chat_completions", Label: "chat completions", Detail: "widely supported by compatible endpoints"},
211	}
212	m.ProtocolPicker = newLoginPick("api protocol", items, m.Draft.Protocol, m.Width, m.Height)
213}
214
215func (m *LoginModel) buildModelPicker() {
216	items := make([]PickerItem, 0, len(m.Catalog)+1)
217	for _, name := range m.Catalog {
218		items = append(items, PickerItem{ID: "model:" + name, Label: name, Note: "from the models.dev catalog"})
219	}
220	items = append(items, PickerItem{ID: "manual", Label: "enter a model ID manually", Note: "type a model ID yourself"})
221	m.ModelPicker = newLoginPick("model", items, "model:"+m.Draft.Model, m.Width, m.Height)
222}
223
224// hasKey reports a profile with an api key, entered now or held by the daemon.
225func hasKey(settings config.Settings) bool {
226	return settings.APIKey != "" || settings.HasKey
227}
228
229func (m LoginModel) fetchCatalogCmd(ext, endpoint string, gen int) tea.Cmd {
230	return func() tea.Msg {
231		note := ""
232		if m.Conn == nil {
233			return loginModelsLoadedMsg{Note: "Could not load the models.dev catalog. Enter a model ID manually.", Gen: gen}
234		}
235		models, err := daemon.ListModels(m.readCtx, m.Conn, ext, endpoint)
236		names := make([]string, len(models))
237		for i, model := range models {
238			names[i] = model.ID
239		}
240		if err != nil {
241			return loginModelsLoadedMsg{Err: err, Note: "Could not load the models.dev catalog. Enter a model ID manually.", Gen: gen}
242		}
243		if len(names) == 0 {
244			note = "No matching models in models.dev. Enter a model ID manually."
245		}
246		return loginModelsLoadedMsg{Models: names, Note: note, Gen: gen}
247	}
248}
249
250// saveProviderCmd saves the profile and optional key through the daemon.
251func (m LoginModel) saveProviderCmd(name string, settings config.Settings) tea.Cmd {
252	return func() tea.Msg {
253		validated, err := settings.Validate()
254		if err != nil {
255			return providerSavedMsg{Gen: m.Generation, Name: name, Settings: settings, Err: err}
256		}
257		err = daemon.SaveAndSelectProvider(context.Background(), m.Conn, name, validated, m.Profiles.ETag)
258		return providerSavedMsg{Gen: m.Generation, Name: name, Settings: validated, Err: err}
259	}
260}