@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.
@@ -1,279 +1,141 @@
1
- import { defineFeature, literal, setting } from "@adeildo/pi-kit";
2
- import type { FeatureScope } from "@adeildo/pi-kit";
3
- import type { Credential, Provider } from "@earendil-works/pi-ai";
1
+ // The accounts feature and its two entry points.
2
+ import { ACCOUNT_STATE, type AccountState, defineFeature, isObject } from "@adeildo/pi-kit";
4
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { Key } from "@earendil-works/pi-tui";
4
+ import { type AutocompleteItem, Key } from "@earendil-works/pi-tui";
6
5
 
6
+ import { claudeCodeVersion } from "../subscription/feature.ts";
7
+ import { anthropicQuota } from "../usage/anthropic.ts";
8
+ import { Quota } from "../usage/quota.ts";
9
+ import { resetIn, windowsOf } from "../usage/read.ts";
10
+ import { refreshStatus } from "./actions.ts";
7
11
  import { activeAccount } from "./active.ts";
8
- import { reason } from "./describe.ts";
9
- import { interactionFor } from "./interaction.ts";
10
- import { liftProvider, nativeOf, type AccountSession } from "./lift.ts";
11
- import { login, loginMethods, type LoginMethod } from "./login.ts";
12
- import { LOGIN_KEY, NAME, STATUS_KEY } from "./names.ts";
13
- import { pin, replay, type Pins } from "./pins.ts";
14
- import { accountRows, DEFAULT_ACCOUNT, DEFAULT_LABEL, refreshStatus } from "./screen.ts";
12
+ import { attachProvider, knownReading, showPlans, type Watch } from "./attach.ts";
13
+ import {
14
+ type Accounts,
15
+ accountCount,
16
+ loginProviders,
17
+ manageAccounts,
18
+ pickAccount,
19
+ } from "./dialogs.ts";
20
+ import { NAME, STATUS_KEY } from "./names.ts";
21
+ import { replay, type Pins } from "./pins.ts";
22
+ import { usageLimitLine } from "./policy.ts";
23
+ import { accountRows } from "./screen.ts";
24
+ import { onAuthFailure, onLimit } from "./settings.ts";
15
25
  import { AccountStore } from "./store.ts";
16
- import type { Account } from "./types.ts";
17
-
18
- /** What happens when an account hits the limit of its plan. */
19
- export const WHEN_LIMITED = ["ask", "switch", "stop"] as const;
20
- export type WhenLimited = (typeof WHEN_LIMITED)[number];
21
-
22
- export const onLimit = setting<WhenLimited>({
23
- id: "accounts.onLimit",
24
- default: "ask",
25
- decoder: literal(...WHEN_LIMITED),
26
- ui: {
27
- section: "Accounts",
28
- label: "When an account hits its limit",
29
- description: "Ask me first, switch on its own, or stop and tell me.",
30
- control: {
31
- type: "choice",
32
- options: [
33
- { value: "ask", label: "ask me first" },
34
- { value: "switch", label: "switch on its own" },
35
- { value: "stop", label: "stop and tell me" },
36
- ],
37
- },
38
- },
39
- });
40
26
 
41
27
  export const accounts = defineFeature({
42
28
  id: "accounts",
43
29
  description: "Use more than one credential of the same provider",
44
30
  tab: "Providers",
45
- settings: [onLimit],
31
+ settings: [onLimit, onAuthFailure],
46
32
  setup(scope) {
47
33
  const store = new AccountStore();
48
34
  const pins: Pins = new Map();
35
+ const watch: Watch = {
36
+ limited: new Map(),
37
+ usage: new Map(),
38
+ quota: new Quota({
39
+ anthropic: anthropicQuota(() =>
40
+ scope.has("subscription") ? claudeCodeVersion.get(scope) : claudeCodeVersion.default,
41
+ ),
42
+ }),
43
+ };
44
+
45
+ const bundle: Accounts = { scope, store, pins, watch };
46
+ let registry: ExtensionContext["modelRegistry"] | undefined;
49
47
 
50
48
  scope.screen.rows((ctx) => accountRows(scope, store, pins, ctx));
51
- scope.on("model_select", (_event, ctx) => refreshStatus(store, pins, ctx));
49
+ // A model of another provider brings that provider's plans, so its quota shows before the first
50
+ // answer instead of after it.
51
+ scope.on("model_select", (event, ctx) => {
52
+ refreshStatus(store, pins, ctx);
53
+ showPlans(store, pins, watch, ctx, event.model.provider);
54
+ });
52
55
  scope.on("session_shutdown", (_event, ctx) => ctx.ui.setStatus(STATUS_KEY, undefined));
53
56
 
57
+ // Whoever draws the account in use asks for it on the spot, so it is never a frame old.
58
+ scope.events.on(ACCOUNT_STATE, (data: unknown) => {
59
+ if (!isObject(data) || typeof data.provider !== "string") return;
60
+ data.state = inUse(watch, store, pins, data.provider);
61
+ });
62
+
63
+ // The line stops pi's own retry.
64
+ scope.on("message_end", (event) => {
65
+ if (event.message.role !== "assistant") return;
66
+ const providerId = event.message.provider;
67
+ const account = activeAccount(store, pins, providerId);
68
+ if (account === undefined) return;
69
+ const note = resetIn(knownReading(watch, providerId, account.id));
70
+ const text = usageLimitLine(event.message, account.label, note);
71
+ if (text === undefined) return;
72
+ return { message: { ...event.message, errorMessage: text } };
73
+ });
74
+
54
75
  scope.onSessionStart((ctx) => {
76
+ registry = ctx.modelRegistry;
55
77
  for (const warning of store.reload()) scope.warn(warning);
56
78
  pins.clear();
79
+ watch.limited.clear();
80
+ watch.usage.clear();
57
81
  for (const [provider, account] of replay(ctx.sessionManager.getBranch())) {
58
82
  pins.set(provider, account);
59
83
  }
60
84
  for (const providerId of new Set([...store.providerIds(), ...pins.keys()])) {
61
- lift(scope, store, pins, ctx, providerId);
85
+ attachProvider(scope, store, pins, watch, ctx, providerId);
62
86
  }
63
87
  refreshStatus(store, pins, ctx);
88
+ showPlans(store, pins, watch, ctx, ctx.model?.provider);
64
89
  });
65
90
 
66
91
  scope.registerShortcut(Key.alt("a"), {
67
- description: `${NAME}: pick the account of the current provider`,
68
- handler: (ctx) => pickAccount(scope, store, pins, ctx),
92
+ description: `${NAME}: switch the account of the current provider for this session`,
93
+ handler: (ctx) => pickAccount(bundle, ctx),
69
94
  });
70
95
 
71
96
  scope.registerCommand("accounts", {
72
- description: `${NAME}: add an account for a provider`,
73
- handler: (args, ctx) => addAccount(scope, store, pins, args, ctx),
97
+ description: `${NAME}: see, switch, rename, remove or add the accounts of a provider`,
98
+ getArgumentCompletions: (prefix) => providerCompletions(registry, store, prefix),
99
+ handler: (args, ctx) => manageAccounts(bundle, args, ctx),
74
100
  });
75
101
  },
76
102
  });
77
103
 
78
- function sessionFor(
79
- scope: FeatureScope,
104
+ /** The providers `/accounts` can take, those with accounts first. */
105
+ function providerCompletions(
106
+ registry: ExtensionContext["modelRegistry"] | undefined,
80
107
  store: AccountStore,
81
- pins: Pins,
82
- ctx: ExtensionContext,
83
- providerId: string,
84
- ): AccountSession {
85
- return {
86
- resolve: () => {
87
- const account = activeAccount(store, pins, providerId);
88
- return account === undefined ? undefined : { id: account.id, credential: account.credential };
89
- },
90
- save: (id, credential) => void store.setCredential(providerId, id, credential),
91
- afterLimit: (currentId, detail) =>
92
- afterLimit(scope, store, pins, ctx, providerId, currentId, detail),
93
- };
94
- }
95
-
96
- /** Follows the `accounts.onLimit` policy: ask, switch, or stop, and pins the account it moves to. */
97
- export async function afterLimit(
98
- scope: FeatureScope,
99
- store: AccountStore,
100
- pins: Pins,
101
- ctx: ExtensionContext,
102
- providerId: string,
103
- currentId: string,
104
- detail: string,
105
- ): Promise<{ id: string; credential: Credential } | undefined> {
106
- const mode = onLimit.get(scope);
107
- if (mode === "stop") return undefined;
108
-
109
- const next = nextAccount(store.accounts(providerId), currentId);
110
- if (next === undefined) return undefined;
111
-
112
- if (mode === "ask") {
113
- const once = `Switch to ${next.label}`;
114
- const always = "Always switch when an account hits its limit";
115
- const picked = await ctx.ui.select(`"${currentId}" hit its limit`, [once, always, "Stop here"]);
116
- if (picked === always) scope.settings.set(onLimit, "switch");
117
- else if (picked !== once) return undefined;
118
- }
119
-
120
- pin(scope, pins, providerId, next.id);
121
- refreshStatus(store, pins, ctx);
122
- ctx.ui.notify(`${NAME}: ${providerId} now uses ${next.label} (${detail})`, "warning");
123
- return { id: next.id, credential: next.credential };
124
- }
125
-
126
- /** The account after this one in the list, so a limit moves on instead of repeating. */
127
- function nextAccount(list: readonly Account[], currentId: string): Account | undefined {
128
- if (list.length < 2) return undefined;
129
- const index = list.findIndex((account) => account.id === currentId);
130
- return list[(index + 1) % list.length];
108
+ prefix: string,
109
+ ): AutocompleteItem[] | null {
110
+ if (registry === undefined) return null;
111
+ const typed = prefix.trim().toLowerCase();
112
+ const items = loginProviders(registry, store)
113
+ .filter((id) => id.toLowerCase().startsWith(typed))
114
+ .map((id) => {
115
+ const name = registry.getProviderDisplayName(id);
116
+ const count = store.accounts(id).length;
117
+ return {
118
+ value: id,
119
+ label: id,
120
+ description: count === 0 ? name : `${name}, ${accountCount(count)}`,
121
+ };
122
+ });
123
+ return items.length === 0 ? null : items;
131
124
  }
132
125
 
133
- function lift(
134
- scope: FeatureScope,
126
+ /** The account in use for a provider, with the windows it is spending. */
127
+ function inUse(
128
+ watch: Watch,
135
129
  store: AccountStore,
136
130
  pins: Pins,
137
- ctx: ExtensionContext,
138
131
  providerId: string,
139
- ): void {
140
- // A pin with no account left behind it injects nothing, so re-registering it would only cost a
141
- // model-registry rebuild.
142
- if (!store.has(providerId)) return;
143
-
144
- // `getProvider` returns the lifted provider once we registered one, so unwrap it before wrapping
145
- // again. After a `/reload` it returns the built-in one, which is unwrapped too.
146
- const provider = ctx.modelRegistry.getProvider(providerId);
147
- if (provider === undefined) {
148
- scope.warn(`no provider "${providerId}", so its accounts do nothing`);
149
- return;
150
- }
151
- scope.registerProvider(
152
- liftProvider(nativeOf(provider), sessionFor(scope, store, pins, ctx, providerId)),
153
- );
154
- }
155
-
156
- async function pickAccount(
157
- scope: FeatureScope,
158
- store: AccountStore,
159
- pins: Pins,
160
- ctx: ExtensionContext,
161
- ): Promise<void> {
162
- if (!ctx.hasUI) {
163
- scope.warn("no dialog to pick an account");
164
- return;
165
- }
166
-
167
- const providerId = ctx.model?.provider;
168
- if (providerId === undefined) {
169
- ctx.ui.notify(`${NAME}: no model selected`, "warning");
170
- return;
171
- }
172
- const entries = store.accounts(providerId);
173
- if (entries.length === 0) {
174
- ctx.ui.notify(`${NAME}: ${providerId} has no extra account yet`, "info");
175
- return;
176
- }
177
-
178
- const current = activeAccount(store, pins, providerId)?.id ?? DEFAULT_ACCOUNT;
179
- const choices = [
180
- { id: DEFAULT_ACCOUNT, label: DEFAULT_LABEL, kind: "login" },
181
- ...entries.map((account) => ({
182
- id: account.id,
183
- label: account.label,
184
- kind: account.credential.type === "oauth" ? "oauth" : "key",
185
- })),
186
- ];
187
- const options = choices.map((choice, index) => {
188
- const here = choice.id === current ? " ✓" : "";
189
- return `${index + 1} ${choice.label} (${choice.kind})${here}`;
190
- });
191
-
192
- const picked = await ctx.ui.select(`Account for ${providerId}`, options);
193
- const index = picked === undefined ? -1 : options.indexOf(picked);
194
- if (index < 0) return;
195
-
196
- const choice = choices[index];
197
- if (choice === undefined) return;
198
- pin(scope, pins, providerId, choice.id === DEFAULT_ACCOUNT ? null : choice.id);
199
- refreshStatus(store, pins, ctx);
200
- ctx.ui.notify(`${NAME}: ${providerId} uses ${choice.label}`, "info");
201
- }
202
-
203
- async function addAccount(
204
- scope: FeatureScope,
205
- store: AccountStore,
206
- pins: Pins,
207
- args: string,
208
- ctx: ExtensionContext,
209
- ): Promise<void> {
210
- if (!ctx.hasUI) {
211
- scope.warn("no dialog to add an account");
212
- return;
213
- }
214
-
215
- const providerId = args.trim() || (await pickProvider(ctx));
216
- if (providerId === undefined) return;
217
- const provider = ctx.modelRegistry.getProvider(providerId);
218
- if (provider === undefined) {
219
- ctx.ui.notify(`${NAME}: no provider "${providerId}"`, "error");
220
- return;
221
- }
222
- const method = await pickMethod(ctx, provider);
223
- if (method === undefined) return;
224
-
225
- const label =
226
- (await ctx.ui.input(`Name this ${providerId} account`, providerId))?.trim() || providerId;
227
- try {
228
- const credential = await login(
229
- method,
230
- interactionFor(ctx, providerId, new AbortController().signal),
231
- );
232
- const added = store.add(providerId, label, credential);
233
- if (added.account === undefined) {
234
- ctx.ui.notify(`${NAME}: ${added.problem}`, "error");
235
- return;
236
- }
237
- store.setActive(providerId, added.account.id);
238
- pin(scope, pins, providerId, added.account.id);
239
- // The first account of a provider was not lifted at session start, so lift it now.
240
- if (store.accounts(providerId).length === 1) lift(scope, store, pins, ctx, providerId);
241
- refreshStatus(store, pins, ctx);
242
- ctx.ui.notify(`${NAME}: ${providerId} account "${label}" added`, "info");
243
- } catch (error) {
244
- ctx.ui.notify(`${NAME}: ${reason(error)}`, "error");
245
- } finally {
246
- ctx.ui.setStatus(LOGIN_KEY, undefined);
247
- }
248
- }
249
-
250
- async function pickProvider(ctx: ExtensionContext): Promise<string | undefined> {
251
- const candidates: string[] = [];
252
- for (const providerId of new Set(ctx.modelRegistry.getAll().map((model) => model.provider))) {
253
- const provider = ctx.modelRegistry.getProvider(providerId);
254
- if (provider !== undefined && loginMethods(provider).length > 0) candidates.push(providerId);
255
- }
256
- const list = candidates.toSorted();
257
- if (list.length === 0) {
258
- ctx.ui.notify(`${NAME}: no provider offers a login`, "warning");
259
- return undefined;
260
- }
261
- if (list.length === 1) return list[0];
262
- return await ctx.ui.select("Provider", list);
263
- }
264
-
265
- async function pickMethod(
266
- ctx: ExtensionContext,
267
- provider: Provider,
268
- ): Promise<LoginMethod | undefined> {
269
- const methods = loginMethods(provider);
270
- if (methods.length === 0) {
271
- ctx.ui.notify(`${NAME}: ${provider.id} has no login method`, "warning");
272
- return undefined;
273
- }
274
- if (methods.length === 1) return methods[0];
275
-
276
- const labels = methods.map((method) => method.label);
277
- const picked = await ctx.ui.select(`${provider.id} login`, labels);
278
- return methods.find((method) => method.label === picked);
132
+ ): AccountState | undefined {
133
+ const account = activeAccount(store, pins, providerId);
134
+ if (account === undefined) return undefined;
135
+ return {
136
+ provider: providerId,
137
+ label: account.label,
138
+ windows: windowsOf(knownReading(watch, providerId, account.id)),
139
+ ...(account.health?.lastError?.kind === "auth" ? { needsLogin: true } : {}),
140
+ };
279
141
  }
@@ -1,11 +1,23 @@
1
- // The provider's own login asks for a code, a URL or a choice. These dialogs answer for it, so no
2
- // OAuth and no key prompt live in this package.
1
+ // The provider's own login asks for a code, a URL or a choice. Our framed screen answers it; a host
2
+ // that cannot draw one falls back to pi's own dialogs.
3
3
  import type { AuthEvent, ProviderAuthInteraction } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
 
6
+ import { type LoginSession, loginScreen } from "../ui/login.ts";
7
+ import { pick } from "../ui/picker.ts";
6
8
  import { LOGIN_KEY } from "./names.ts";
7
9
 
8
- export function interactionFor(
10
+ export async function loginFor(
11
+ ctx: ExtensionContext,
12
+ label: string,
13
+ signal: AbortSignal,
14
+ ): Promise<LoginSession> {
15
+ const screen = await loginScreen(ctx, label, signal);
16
+ return screen ?? { interaction: dialogs(ctx, label, signal), close: () => {} };
17
+ }
18
+
19
+ /** Pi's own dialogs, for a host without our screen. */
20
+ function dialogs(
9
21
  ctx: ExtensionContext,
10
22
  label: string,
11
23
  signal: AbortSignal,
@@ -15,7 +27,7 @@ export function interactionFor(
15
27
  async prompt(prompt) {
16
28
  if (prompt.type === "select") {
17
29
  const options = prompt.options.map((option) => option.label);
18
- const picked = await ctx.ui.select(prompt.message, options, { signal: prompt.signal });
30
+ const picked = await pick(ctx, prompt.message, options, { signal: prompt.signal });
19
31
  if (picked === undefined) throw new Error(`${label}: login cancelled`);
20
32
  return prompt.options.find((option) => option.label === picked)?.id ?? picked;
21
33
  }