@adeildo/pi-providers 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,332 @@
1
+ // The picker behind Alt+A and the list behind /accounts.
2
+ import type { FeatureScope } from "@adeildo/pi-kit";
3
+ import type { Credential, Provider } from "@earendil-works/pi-ai";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ import {
7
+ type AccountRow,
8
+ type AccountState,
9
+ type AccountsChoice,
10
+ type AccountsListing,
11
+ describeState,
12
+ showAccounts,
13
+ } from "../ui/accounts.ts";
14
+ import { pick, type PickOption } from "../ui/picker.ts";
15
+ import { timeLeft, windowsOf } from "../usage/read.ts";
16
+ import type { AccountUsage } from "../usage/types.ts";
17
+ import {
18
+ type AccountsOf,
19
+ askName,
20
+ refreshStatus,
21
+ removeAccount,
22
+ removeQuestion,
23
+ renameAccount,
24
+ saveAccount,
25
+ useAccount,
26
+ } from "./actions.ts";
27
+ import { activeAccount } from "./active.ts";
28
+ import { attachProvider, knownReading, readPlans, showPlans, type Watch } from "./attach.ts";
29
+ import { reason } from "./describe.ts";
30
+ import { loginFor } from "./interaction.ts";
31
+ import { nativeOf } from "./lift.ts";
32
+ import { kindText, login, loginMethods, type LoginMethod, piLogin } from "./login.ts";
33
+ import { LOGIN_KEY, NAME } from "./names.ts";
34
+ import { pin, type Pins } from "./pins.ts";
35
+ import { signInAgain } from "./policy.ts";
36
+ import type { AccountStore } from "./store.ts";
37
+ import type { Account } from "./types.ts";
38
+
39
+ /** Everything the dialogs share. */
40
+ export interface Accounts {
41
+ scope: FeatureScope;
42
+ store: AccountStore;
43
+ pins: Pins;
44
+ watch: Watch;
45
+ }
46
+
47
+ type Registry = ExtensionContext["modelRegistry"];
48
+
49
+ /** Alt+A: the account of the current provider, pinned for the session. */
50
+ export async function pickAccount(accounts: Accounts, ctx: ExtensionContext): Promise<void> {
51
+ const { scope, store, pins, watch } = accounts;
52
+ if (!ctx.hasUI) {
53
+ scope.warn("no dialog to pick an account");
54
+ return;
55
+ }
56
+
57
+ const providerId = ctx.model?.provider;
58
+ if (providerId === undefined) {
59
+ ctx.ui.notify(`${NAME}: no model selected`, "warning");
60
+ return;
61
+ }
62
+ const provider = ctx.modelRegistry.getProviderDisplayName(providerId);
63
+ const entries = store.accounts(providerId);
64
+ if (entries.length === 0) {
65
+ ctx.ui.notify(`${NAME}: ${provider} has no accounts yet. /accounts adds one`, "info");
66
+ return;
67
+ }
68
+
69
+ const current = activeAccount(store, pins, providerId)?.id;
70
+ const theme = ctx.ui.theme;
71
+ const options: PickOption[] = entries.map((account) => {
72
+ const kind = theme.fg("dim", `(${kindText(account.credential.type)})`);
73
+ const here = account.id === current ? ` ${theme.fg("success", "✓")}` : "";
74
+ const label = `${account.label} ${kind}${here}`;
75
+ const usage = knownReading(watch, providerId, account.id);
76
+ const description = describeState(theme, stateOf(account, usage, false));
77
+ return description === "" ? { label } : { label, description };
78
+ });
79
+
80
+ const picked = await pick(ctx, `Account for ${provider}`, options);
81
+ const choice = entries[options.findIndex((option) => option.label === picked)];
82
+ if (choice === undefined) return;
83
+ pin(scope, pins, providerId, choice.id);
84
+ refreshStatus(store, pins, ctx);
85
+ ctx.ui.notify(`${NAME}: ${provider} uses ${choice.label}`, "info");
86
+ }
87
+
88
+ /**
89
+ * /accounts: the accounts of a provider, the current model's unless one is named. Enter uses one,
90
+ * here and in the sessions that start after; the list renames, removes and adds them too.
91
+ */
92
+ export async function manageAccounts(
93
+ accounts: Accounts,
94
+ args: string,
95
+ ctx: ExtensionContext,
96
+ ): Promise<void> {
97
+ const { scope, store, pins, watch } = accounts;
98
+ if (!ctx.hasUI) {
99
+ scope.warn("no dialog to manage accounts");
100
+ return;
101
+ }
102
+
103
+ const providerId = args.trim() || ctx.model?.provider || (await pickProvider(store, ctx));
104
+ if (providerId === undefined) return;
105
+ if (ctx.modelRegistry.getProvider(providerId) === undefined) {
106
+ ctx.ui.notify(`${NAME}: no provider "${providerId}"`, "error");
107
+ return;
108
+ }
109
+ const provider = ctx.modelRegistry.getProviderDisplayName(providerId);
110
+ const of: AccountsOf = { pi: scope, store, pins, providerId };
111
+
112
+ let reading = true;
113
+ const ready = readPlans(store, watch, ctx, providerId).finally(() => {
114
+ reading = false;
115
+ });
116
+ const listing: AccountsListing = {
117
+ provider,
118
+ rows: () =>
119
+ store.accounts(providerId).map((account) => rowOf(accounts, providerId, account, reading)),
120
+ empty: emptyText(providerId, provider),
121
+ rename: (id, label) => renameAccount(of, ctx, id, label),
122
+ remove: (id) => removeAccount(of, ctx, id),
123
+ removeQuestion: (row) =>
124
+ removeQuestion(provider, row.label, store.accounts(providerId).length === 1),
125
+ };
126
+
127
+ const choice =
128
+ ctx.mode === "tui" && typeof ctx.ui.custom === "function"
129
+ ? await showAccounts(ctx, listing, ready)
130
+ : await plainList(ctx, listing);
131
+ if (choice === undefined) return;
132
+ if (choice.kind === "add") {
133
+ await addAccount(accounts, providerId, ctx);
134
+ return;
135
+ }
136
+
137
+ const account = store.accounts(providerId).find((entry) => entry.id === choice.id);
138
+ if (account === undefined) return;
139
+ if (
140
+ choice.kind === "signIn" &&
141
+ (await signInAgain(store, ctx, providerId, account)) === undefined
142
+ )
143
+ return;
144
+ useAccount(of, ctx, account.id);
145
+ ctx.ui.notify(`${NAME}: ${provider} uses ${account.label}`, "info");
146
+ }
147
+
148
+ /** A host without our frame gets the list as a plain select: use one, or add one. */
149
+ async function plainList(
150
+ ctx: ExtensionContext,
151
+ listing: AccountsListing,
152
+ ): Promise<AccountsChoice | undefined> {
153
+ const rows = listing.rows();
154
+ const add = "Add an account";
155
+ const labels = rows.map((row) => (row.inUse ? `${row.label} (in use)` : row.label));
156
+ const picked = await pick(ctx, `${listing.provider} accounts`, [...labels, add]);
157
+ if (picked === add) return { kind: "add" };
158
+ const row = rows[labels.indexOf(picked ?? "")];
159
+ return row === undefined ? undefined : { kind: "use", id: row.id };
160
+ }
161
+
162
+ /** One account as the list draws it. */
163
+ function rowOf(
164
+ accounts: Accounts,
165
+ providerId: string,
166
+ account: Account,
167
+ reading: boolean,
168
+ ): AccountRow {
169
+ const { store, pins, watch } = accounts;
170
+ const usage = knownReading(watch, providerId, account.id);
171
+ const readable = reading && account.credential.type === "oauth" && watch.quota.reads(providerId);
172
+ return {
173
+ id: account.id,
174
+ label: account.label,
175
+ kind: kindText(account.credential.type),
176
+ inUse: activeAccount(store, pins, providerId)?.id === account.id,
177
+ state: stateOf(account, usage, readable),
178
+ };
179
+ }
180
+
181
+ /** What a row says: the account's own trouble first, else how its plan stands. */
182
+ function stateOf(
183
+ account: Account,
184
+ usage: AccountUsage | undefined,
185
+ reading: boolean,
186
+ ): AccountState {
187
+ if (account.health?.lastError?.kind === "auth") return { kind: "signIn" };
188
+ const windows = windowsOf(usage);
189
+ if (windows.length > 0) return { kind: "quota", windows };
190
+ if (reading) return { kind: "reading" };
191
+ const left = timeLeft(account.health?.limitedUntil ?? 0);
192
+ return left === undefined ? { kind: "quiet" } : { kind: "spent", resetsIn: left };
193
+ }
194
+
195
+ /** What an empty list says, which mentions pi's own login when there is one to bring along. */
196
+ function emptyText(providerId: string, provider: string): string {
197
+ const existing = piLogin(providerId);
198
+ if (existing === undefined) return `No ${provider} accounts yet.`;
199
+ const kind = kindText(existing.type);
200
+ return `No ${provider} accounts yet. Adding one keeps your current ${kind} login as an account too.`;
201
+ }
202
+
203
+ /**
204
+ * Names a new credential and runs the provider's own login. The first account makes the provider
205
+ * ours, so pi's own login comes along under a name of its own: from then on only accounts are read.
206
+ */
207
+ export async function addAccount(
208
+ accounts: Accounts,
209
+ providerId: string,
210
+ ctx: ExtensionContext,
211
+ ): Promise<void> {
212
+ const { scope, store, pins, watch } = accounts;
213
+ const registered = ctx.modelRegistry.getProvider(providerId);
214
+ if (registered === undefined) {
215
+ ctx.ui.notify(`${NAME}: no provider "${providerId}"`, "error");
216
+ return;
217
+ }
218
+ const provider = ctx.modelRegistry.getProviderDisplayName(providerId);
219
+ // This flow keeps the credential itself, so the login runs on the provider underneath: the lifted
220
+ // one adopts what its login returns, which would name and add the account a second time.
221
+ const method = await pickMethod(ctx, nativeOf(registered), provider);
222
+ if (method === undefined) return;
223
+
224
+ const first = !store.has(providerId);
225
+ const carried = first ? await namePiLogin(ctx, providerId, provider) : undefined;
226
+ const suggestion = suggestName(store, providerId, provider, carried?.label);
227
+ const label = await askName(ctx, `Name the new ${provider} account`, suggestion);
228
+
229
+ const session = await loginFor(ctx, provider, new AbortController().signal);
230
+ try {
231
+ const credential = await login(method, session.interaction);
232
+ if (carried !== undefined) {
233
+ const kept = saveAccount(ctx, store, providerId, carried.label, carried.credential);
234
+ if (kept === undefined) return;
235
+ }
236
+ const added = saveAccount(ctx, store, providerId, label, credential);
237
+ if (added === undefined) return;
238
+ useAccount({ pi: scope, store, pins, providerId }, ctx, added.id);
239
+ // The first account of a provider was not attached at session start, so attach it now.
240
+ if (first) attachProvider(scope, store, pins, watch, ctx, providerId);
241
+ ctx.ui.notify(`${NAME}: ${provider} now uses ${label}`, "info");
242
+ showPlans(store, pins, watch, ctx, providerId);
243
+ } catch (error) {
244
+ ctx.ui.notify(`${NAME}: ${reason(error)}`, "error");
245
+ } finally {
246
+ session.close();
247
+ ctx.ui.setStatus(LOGIN_KEY, undefined);
248
+ }
249
+ }
250
+
251
+ /** Pi's own login of a provider, with the name it keeps as an account, when there is one. */
252
+ async function namePiLogin(
253
+ ctx: ExtensionContext,
254
+ providerId: string,
255
+ provider: string,
256
+ ): Promise<{ label: string; credential: Credential } | undefined> {
257
+ const credential = piLogin(providerId);
258
+ if (credential === undefined) return undefined;
259
+ const title = `Name the ${provider} ${kindText(credential.type)} login you already have`;
260
+ return { label: await askName(ctx, title, provider), credential };
261
+ }
262
+
263
+ /** The provider's name for its first account, then the same name numbered. */
264
+ function suggestName(
265
+ store: AccountStore,
266
+ providerId: string,
267
+ provider: string,
268
+ alongside: string | undefined,
269
+ ): string {
270
+ const taken = new Set(store.accounts(providerId).map((account) => account.label));
271
+ if (alongside !== undefined) taken.add(alongside);
272
+ let candidate = provider;
273
+ for (let number = 2; taken.has(candidate); number++) candidate = `${provider} ${number}`;
274
+ return candidate;
275
+ }
276
+
277
+ /** The providers a login can add an account to, those with accounts first. */
278
+ export function loginProviders(registry: Registry, store: AccountStore): string[] {
279
+ const ids = new Set(registry.getAll().map((model) => model.provider));
280
+ return [...ids]
281
+ .filter((id) => {
282
+ const provider = registry.getProvider(id);
283
+ return provider !== undefined && loginMethods(nativeOf(provider)).length > 0;
284
+ })
285
+ .toSorted(
286
+ (a, b) =>
287
+ store.accounts(b).length - store.accounts(a).length ||
288
+ registry.getProviderDisplayName(a).localeCompare(registry.getProviderDisplayName(b)),
289
+ );
290
+ }
291
+
292
+ /** How many accounts a provider has, as a picker says it. */
293
+ export function accountCount(count: number): string {
294
+ return `${count} account${count === 1 ? "" : "s"}`;
295
+ }
296
+
297
+ /** The provider to manage, each with what it has. */
298
+ async function pickProvider(
299
+ store: AccountStore,
300
+ ctx: ExtensionContext,
301
+ ): Promise<string | undefined> {
302
+ const ids = loginProviders(ctx.modelRegistry, store);
303
+ if (ids.length === 0) {
304
+ ctx.ui.notify(`${NAME}: no provider offers a login`, "warning");
305
+ return undefined;
306
+ }
307
+ const options: PickOption[] = ids.map((id) => {
308
+ const label = ctx.modelRegistry.getProviderDisplayName(id);
309
+ const count = store.accounts(id).length;
310
+ if (count > 0) return { label, description: accountCount(count) };
311
+ return piLogin(id) === undefined ? { label } : { label, description: "logged in" };
312
+ });
313
+ const picked = await pick(ctx, "Accounts for", options);
314
+ return ids[options.findIndex((option) => option.label === picked)];
315
+ }
316
+
317
+ async function pickMethod(
318
+ ctx: ExtensionContext,
319
+ provider: Provider,
320
+ name: string,
321
+ ): Promise<LoginMethod | undefined> {
322
+ const methods = loginMethods(provider);
323
+ if (methods.length === 0) {
324
+ ctx.ui.notify(`${NAME}: ${name} has no login method`, "warning");
325
+ return undefined;
326
+ }
327
+ if (methods.length === 1) return methods[0];
328
+
329
+ const labels = methods.map((method) => method.label);
330
+ const picked = await pick(ctx, `How to sign in to ${name}`, labels);
331
+ return methods.find((method) => method.label === picked);
332
+ }