okami.mom/albedo

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

nekomimi.petfeat(login): configure Vertex and Bedrock from /login3b308f0

main
9.6 KiB276 linesraw
1//go:build unix
2
3// The manual openai-compatible provider wizard crosses the composer, the
4// daemon's sign-in and model-catalog answers, config.json on disk, and the
5// daemon's creds.json. A stubbed daemon cannot prove the real answers lead the
6// wizard to that save, or that the key it hands over is the one requests use.
7package e2e
8
9import (
10	"context"
11	"os"
12	"path/filepath"
13	"slices"
14	"strings"
15	"testing"
16
17	"albedo/cli/internal/config"
18	"albedo/cli/internal/daemon"
19	"albedo/cli/internal/tui"
20
21	tea "charm.land/bubbletea/v2"
22)
23
24func TestTUILoginAddsAManualProviderThroughTheDaemon(t *testing.T) {
25	name, key, model := "wanderer", "sk-wanderer-fixture", "hermit-mini"
26	providerRoute(t, echoReply)
27	// The wizard uses the suite daemon for profile persistence.
28	t.Setenv("ALBEDO_HOME", suite.home)
29	preserveLoginState(t, name)
30	before, err := daemon.ProviderProfiles(context.Background(), conn(t))
31	if err != nil {
32		t.Fatalf("suite config: %v", err)
33	}
34	baseURL := before.Providers[t.Name()].BaseURL
35	d := newTUIDriver(t)
36	// expect fails unless the wizard is asking this question next.
37	expect := func(want tui.LoginStep) {
38		t.Helper()
39		if d.App.Login.Step != want {
40			t.Fatalf("the wizard is on step %v, want %v", d.App.Login.Step, want)
41		}
42	}
43
44	d.App.Chat.TextArea.SetValue("/login " + name)
45	opened, ok := d.Key(tea.KeyEnter).(tui.ChatOpenLoginMsg)
46	if !ok || opened.Name != name {
47		t.Fatalf("enter did not open /login: %#v", opened)
48	}
49	// Login.Init asks the daemon what can be signed in to; only its answer
50	// applies the hint, which lands on the wizard's base-url question.
51	d.Dispatch(opened)
52	if d.App.State != tui.AppStateLogin {
53		t.Fatalf("the daemon's sign-in answer did not route the hint: state=%v\n%s", d.App.State, d.View())
54	}
55	expect(tui.StepBaseURL)
56
57	d.App.Login.TextInput.SetValue(baseURL)
58	d.Key(tea.KeyEnter)
59	expect(tui.StepAPIKey)
60	d.App.Login.TextInput.SetValue(key)
61	d.Key(tea.KeyEnter)
62	expect(tui.StepProtocol)
63
64	// The keyboard alone moves the protocol picker off the default responses.
65	d.Key(tea.KeyDown)
66	picked, ok := d.Key(tea.KeyEnter).(tui.PickerSelectMsg)
67	if !ok || picked.ID != "chat_completions" {
68		t.Fatalf("enter did not pick the protocol: %#v", picked)
69	}
70	// The daemon may advertise endpoint defaults even without models.dev.
71	// Manual entry remains available alongside every advertised model.
72	d.Dispatch(picked)
73	expect(tui.StepModels)
74	for {
75		selected, ok := d.App.Login.ModelPicker.Highlighted()
76		if !ok {
77			t.Fatal("model picker has no manual-entry row")
78		}
79		if selected.ID == "manual" {
80			break
81		}
82		d.Key(tea.KeyDown)
83	}
84	manual, ok := d.Key(tea.KeyEnter).(tui.PickerSelectMsg)
85	if !ok || manual.ID != "manual" {
86		t.Fatalf("manual entry was not selectable: %#v", manual)
87	}
88	d.Dispatch(manual)
89	expect(tui.StepModel)
90
91	d.App.Login.TextInput.SetValue(model)
92	// The save lands in config.json; the app reloads it and returns to chat.
93	d.Dispatch(d.Key(tea.KeyEnter))
94	if d.App.State != tui.AppStateChat || !strings.Contains(d.View(), "› ") || d.App.Profiles.Active != name {
95		t.Fatalf("the app did not return to chat with %q active: state=%v active=%q\n%s", name, d.App.State, d.App.Profiles.Active, d.View())
96	}
97
98	profiles, err := daemon.ProviderProfiles(context.Background(), conn(t))
99	if err != nil {
100		t.Fatalf("saved config: %v", err)
101	}
102	got := profiles.Providers[name]
103	want := config.Settings{ProfileName: name, Extension: "openai", BaseURL: baseURL, Model: model, Protocol: "chat_completions", HasKey: true}
104	if got != want {
105		t.Fatalf("config.json kept %+v, want %+v", got, want)
106	}
107	if profiles.Active != name {
108		t.Fatalf("config.json kept %q active, not %q", profiles.Active, name)
109	}
110	// The key went to the daemon, which sends it with the profile's requests.
111	id := cliSession(t, t.TempDir())
112	cli(t, "send", id, "which key")
113	waitIdle(t, id, t.Name(), 1)
114	if requests := suite.provider.requests(t.Name()); requests[len(requests)-1]["authorization"] != "Bearer "+key {
115		t.Fatalf("the provider saw authorization %v, not the saved key", requests[len(requests)-1]["authorization"])
116	}
117}
118
119// Vertex and Bedrock authenticate from the daemon host's own credentials, so
120// the wizard asks for a project and location (Vertex) or lets the endpoint and
121// key stay blank (Bedrock) and saves a profile with no key.
122func TestTUILoginSavesCloudProviderProfiles(t *testing.T) {
123	providerRoute(t, echoReply)
124	t.Setenv("ALBEDO_HOME", suite.home)
125	preserveLoginState(t, "vertex")
126	preserveLoginState(t, "bedrock")
127	d := newTUIDriver(t)
128	expect := func(want tui.LoginStep) {
129		t.Helper()
130		if d.App.Login.Step != want {
131			t.Fatalf("the wizard is on step %v, want %v\n%s", d.App.Login.Step, want, d.View())
132		}
133	}
134	start := func(id, name string) {
135		t.Helper()
136		d.App.Chat.TextArea.SetValue("/login")
137		d.Dispatch(d.Key(tea.KeyEnter))
138		expect(tui.StepChoose)
139		d.Dispatch(tui.PickerSelectMsg{ID: id})
140		expect(tui.StepName)
141		d.App.Login.TextInput.SetValue(name)
142		d.Key(tea.KeyEnter)
143	}
144	finish := func(model string) config.Settings {
145		t.Helper()
146		expect(tui.StepModels)
147		d.Dispatch(tui.PickerSelectMsg{ID: "manual"})
148		expect(tui.StepModel)
149		d.App.Login.TextInput.SetValue(model)
150		d.Dispatch(d.Key(tea.KeyEnter))
151		if d.App.State != tui.AppStateChat {
152			t.Fatalf("the app did not return to chat: state=%v\n%s", d.App.State, d.View())
153		}
154		profiles, err := daemon.ProviderProfiles(context.Background(), conn(t))
155		if err != nil {
156			t.Fatalf("saved config: %v", err)
157		}
158		return profiles.Providers[d.App.Profiles.Active]
159	}
160
161	start("add-vertex", "vertex")
162	expect(tui.StepProject)
163	d.App.Login.TextInput.SetValue("fixture-project")
164	d.Key(tea.KeyEnter)
165	expect(tui.StepLocation)
166	d.App.Login.TextInput.SetValue("europe-west4")
167	d.Dispatch(d.Key(tea.KeyEnter))
168	got := finish("gemini-fixture")
169	if got.Extension != "vertex" || got.Model != "gemini-fixture" || got.HasKey ||
170		deref(got.Project) != "fixture-project" || deref(got.Location) != "europe-west4" {
171		t.Fatalf("config.json kept %+v", got)
172	}
173
174	// Blank answers fall back to the daemon host's AWS environment.
175	start("add-bedrock", "bedrock")
176	expect(tui.StepBaseURL)
177	d.Key(tea.KeyEnter)
178	expect(tui.StepAPIKey)
179	d.Dispatch(d.Key(tea.KeyEnter))
180	got = finish("anthropic.claude-fixture")
181	if got.Extension != "bedrock" || got.BaseURL != "" || got.HasKey || got.Project != nil {
182		t.Fatalf("config.json kept %+v", got)
183	}
184}
185
186func deref(value *string) string {
187	if value == nil {
188		return ""
189	}
190	return *value
191}
192
193// preserveLoginState registers cleanup that deletes the temporary provider and
194// its API key before restoring the saved config.
195func preserveLoginState(t *testing.T, profile string) {
196	t.Helper()
197	configPath := filepath.Join(suite.home, "config.json")
198	saved, err := os.ReadFile(configPath)
199	if err != nil {
200		t.Fatal(err)
201	}
202	connection := conn(t)
203	t.Cleanup(func() {
204		profiles, readErr := daemon.ProviderProfiles(context.Background(), connection)
205		if readErr != nil {
206			t.Error(readErr)
207			return
208		}
209		if err := daemon.DeleteProvider(context.Background(), connection, profile, profiles); err != nil {
210			t.Errorf("delete temporary provider %q: %v", profile, err)
211		}
212		if err := os.WriteFile(configPath, saved, 0o600); err != nil {
213			t.Errorf("restore login config: %v", err)
214		}
215	})
216}
217
218// An Anthropic API key rides the claude extension that otherwise signs in,
219// so the wizard must ask for the key instead of starting a sign-in, and a
220// saved key profile must stay usable with no Claude account signed in.
221func TestTUILoginSavesAnAnthropicAPIKeyProfile(t *testing.T) {
222	// Naming the profile after the sign-in provider is the hardest case.
223	name, key, model := "claude", "sk-ant-fixture", "claude-fixture"
224	providerRoute(t, echoReply)
225	t.Setenv("ALBEDO_HOME", suite.home)
226	preserveLoginState(t, name)
227	d := newTUIDriver(t)
228	expect := func(want tui.LoginStep) {
229		t.Helper()
230		if d.App.Login.Step != want {
231			t.Fatalf("the wizard is on step %v, want %v\n%s", d.App.Login.Step, want, d.View())
232		}
233	}
234
235	d.App.Chat.TextArea.SetValue("/login")
236	d.Dispatch(d.Key(tea.KeyEnter))
237	expect(tui.StepChoose)
238	if !slices.ContainsFunc(d.App.Login.SignIns, func(s daemon.SignIn) bool { return s.Provider == "claude" }) {
239		t.Fatalf("the daemon offers no claude sign-in to route around: %+v", d.App.Login.SignIns)
240	}
241	d.Dispatch(tui.PickerSelectMsg{ID: "add-anthropic"})
242	expect(tui.StepName)
243	d.App.Login.TextInput.SetValue(name)
244	d.Key(tea.KeyEnter)
245	// The extension owns its endpoint, so no base url is asked.
246	expect(tui.StepAPIKey)
247	d.App.Login.TextInput.SetValue(key)
248	d.Dispatch(d.Key(tea.KeyEnter))
249	expect(tui.StepModels)
250	d.Dispatch(tui.PickerSelectMsg{ID: "manual"})
251	expect(tui.StepModel)
252	d.App.Login.TextInput.SetValue(model)
253	d.Dispatch(d.Key(tea.KeyEnter))
254	if d.App.State != tui.AppStateChat || d.App.Profiles.Active != name {
255		t.Fatalf("the app did not return to chat with %q active: state=%v active=%q\n%s", name, d.App.State, d.App.Profiles.Active, d.View())
256	}
257	profiles, err := daemon.ProviderProfiles(context.Background(), conn(t))
258	if err != nil {
259		t.Fatalf("saved config: %v", err)
260	}
261	want := config.Settings{ProfileName: name, Extension: "claude", Model: model, Protocol: "chat_completions", HasKey: true}
262	if got := profiles.Providers[name]; got != want {
263		t.Fatalf("config.json kept %+v, want %+v", got, want)
264	}
265	held, err := daemon.GetSettings(context.Background(), conn(t))
266	if err != nil || !slices.Contains(held.Credentials.Providers, name) {
267		t.Fatalf("the daemon holds no key for %q: %+v %v", name, held, err)
268	}
269
270	// Re-selecting the key profile saves at once rather than signing in.
271	d.App.Chat.TextArea.SetValue("/login " + name)
272	d.Dispatch(d.Key(tea.KeyEnter))
273	if d.App.State != tui.AppStateChat || d.App.Profiles.Active != name {
274		t.Fatalf("re-selecting %q left state=%v step=%v\n%s", name, d.App.State, d.App.Login.Step, d.View())
275	}
276}