@adeildo/pi-providers 5.1.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,197 @@
1
+ // What the accounts do when the provider refuses a request, by the settings the user chose.
2
+ import type { FeatureScope } from "@adeildo/pi-kit";
3
+ import type { Credential } from "@earendil-works/pi-ai";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { classifyFailure } from "../errors.ts";
7
+ import { pick } from "../ui/picker.ts";
8
+ import { refreshStatus } from "./actions.ts";
9
+ import { reason } from "./describe.ts";
10
+ import { loginFor } from "./interaction.ts";
11
+ import { nativeOf } from "./lift.ts";
12
+ import { login, loginMethods } from "./login.ts";
13
+ import { LOGIN_KEY, NAME } from "./names.ts";
14
+ import { pin, type Pins } from "./pins.ts";
15
+ import { onAuthFailure, onLimit } from "./settings.ts";
16
+ import type { AccountStore } from "./store.ts";
17
+ import type { Account } from "./types.ts";
18
+
19
+ export function usageLimitLine(
20
+ message: { provider: string; stopReason?: string; errorMessage?: string },
21
+ label: string,
22
+ note?: string,
23
+ ): string | undefined {
24
+ if (message.stopReason !== "error" || message.errorMessage === undefined) return undefined;
25
+ if (classifyFailure(message.errorMessage, message.provider).kind !== "usage") return undefined;
26
+ // The marker stops pi's own retry; the window only resets at the provider.
27
+ const when = note === undefined ? "" : `, ${note}`;
28
+ return `${message.provider} · ${label}: usage limit reached${when} (quota exceeded)`;
29
+ }
30
+
31
+ /** Follows the `accounts.onLimit` policy: ask, switch, or stop, and pins the account it moves to. */
32
+ export async function afterLimit(
33
+ scope: FeatureScope,
34
+ store: AccountStore,
35
+ pins: Pins,
36
+ ctx: ExtensionContext,
37
+ providerId: string,
38
+ currentId: string,
39
+ detail: string,
40
+ limited: Map<string, Set<string>> = new Map(),
41
+ ): Promise<{ id: string; credential: Credential } | undefined> {
42
+ const refused = markLimited(limited, providerId, currentId);
43
+ const mode = onLimit.get(scope);
44
+ if (mode === "stop") return undefined;
45
+
46
+ const next = nextAccount(store.accounts(providerId), currentId, refused);
47
+ if (next === undefined) return undefined;
48
+
49
+ if (mode === "ask") {
50
+ const current = store.accounts(providerId).find((entry) => entry.id === currentId);
51
+ const switchNow = `Switch to ${next.label}`;
52
+ const always = "Always switch when an account hits its limit";
53
+ const picked = await pick(ctx, `"${current?.label ?? providerId}" hit its limit`, [
54
+ switchNow,
55
+ always,
56
+ "Stop here",
57
+ ]);
58
+ if (picked === always) scope.settings.set(onLimit, "switch");
59
+ else if (picked !== switchNow) return undefined;
60
+ }
61
+
62
+ return switchTo(scope, store, pins, ctx, providerId, next, detail);
63
+ }
64
+
65
+ /** Follows the `accounts.onAuthFailure` policy: sign in again, switch, or stop. */
66
+ export async function afterAuthFailure(
67
+ scope: FeatureScope,
68
+ store: AccountStore,
69
+ pins: Pins,
70
+ ctx: ExtensionContext,
71
+ providerId: string,
72
+ currentId: string,
73
+ detail: string,
74
+ ): Promise<{ id: string; credential: Credential } | undefined> {
75
+ const account = store.accounts(providerId).find((entry) => entry.id === currentId);
76
+ if (account !== undefined) store.markError(providerId, currentId, "auth", detail);
77
+
78
+ const mode = onAuthFailure.get(scope);
79
+ if (mode === "stop") return undefined;
80
+
81
+ const next = nextAccount(store.accounts(providerId), currentId, new Set());
82
+ if (mode === "switch") {
83
+ return next === undefined
84
+ ? undefined
85
+ : switchTo(scope, store, pins, ctx, providerId, next, detail);
86
+ }
87
+
88
+ const again = account === undefined ? undefined : `Sign in to ${account.label} again`;
89
+ const other = next === undefined ? undefined : `Switch to ${next.label}`;
90
+ const options: string[] = [];
91
+ if (again !== undefined) options.push(again);
92
+ if (other !== undefined) options.push(other);
93
+ options.push("Stop here");
94
+ if (options.length === 1) return undefined;
95
+
96
+ const title =
97
+ account === undefined
98
+ ? `${providerId} refused the login`
99
+ : `${providerId} refused the login of "${account.label}"`;
100
+ const picked = await pick(ctx, title, options);
101
+ if (picked === again && account !== undefined) {
102
+ return await signInAgain(store, ctx, providerId, account, detail);
103
+ }
104
+ if (picked === other && next !== undefined) {
105
+ return switchTo(scope, store, pins, ctx, providerId, next, detail);
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** Runs the provider's own login again, keeping the account's name and its pin. */
111
+ export async function signInAgain(
112
+ store: AccountStore,
113
+ ctx: ExtensionContext,
114
+ providerId: string,
115
+ account: Account,
116
+ detail?: string,
117
+ ): Promise<{ id: string; credential: Credential } | undefined> {
118
+ const registered = ctx.modelRegistry.getProvider(providerId);
119
+ // The native provider, so the login does not pass through the lift and ask for a name again.
120
+ const provider = registered === undefined ? undefined : nativeOf(registered);
121
+ if (provider === undefined) return undefined;
122
+ const method = loginMethods(provider).find(
123
+ (candidate) => candidate.authType === account.credential.type,
124
+ );
125
+ if (method === undefined) {
126
+ ctx.ui.notify(`${NAME}: ${providerId} has no login for this account`, "warning");
127
+ return undefined;
128
+ }
129
+ const session = await loginFor(
130
+ ctx,
131
+ `${providerId} · ${account.label}`,
132
+ new AbortController().signal,
133
+ );
134
+ try {
135
+ const credential = await login(method, session.interaction);
136
+ const problem = store.setCredential(providerId, account.id, credential);
137
+ if (problem !== undefined) {
138
+ ctx.ui.notify(`${NAME}: ${problem}`, "error");
139
+ return undefined;
140
+ }
141
+ store.clearError(providerId, account.id);
142
+ const why = detail === undefined ? "" : ` (${detail})`;
143
+ ctx.ui.notify(`${NAME}: ${providerId} · ${account.label} signed in again${why}`, "info");
144
+ return { id: account.id, credential };
145
+ } catch (error) {
146
+ ctx.ui.notify(`${NAME}: ${reason(error)}`, "error");
147
+ return undefined;
148
+ } finally {
149
+ session.close();
150
+ ctx.ui.setStatus(LOGIN_KEY, undefined);
151
+ }
152
+ }
153
+
154
+ function switchTo(
155
+ scope: FeatureScope,
156
+ store: AccountStore,
157
+ pins: Pins,
158
+ ctx: ExtensionContext,
159
+ providerId: string,
160
+ next: Account,
161
+ detail: string,
162
+ ): { id: string; credential: Credential } {
163
+ pin(scope, pins, providerId, next.id);
164
+ refreshStatus(store, pins, ctx);
165
+ ctx.ui.notify(`${NAME}: ${providerId} now uses ${next.label} (${detail})`, "warning");
166
+ return { id: next.id, credential: next.credential };
167
+ }
168
+
169
+ /** Remembers the accounts that refused in this session, so a switch does not try them again. */
170
+ function markLimited(
171
+ limited: Map<string, Set<string>>,
172
+ providerId: string,
173
+ accountId: string,
174
+ ): Set<string> {
175
+ const refused = limited.get(providerId) ?? new Set<string>();
176
+ refused.add(accountId);
177
+ limited.set(providerId, refused);
178
+ return refused;
179
+ }
180
+
181
+ /** The account after this one that has not refused yet. */
182
+ function nextAccount(
183
+ list: readonly Account[],
184
+ currentId: string,
185
+ refused: ReadonlySet<string>,
186
+ ): Account | undefined {
187
+ if (list.length === 0) return undefined;
188
+ const index = list.findIndex((account) => account.id === currentId);
189
+ // An account removed while it served is no longer in the list, so every account is a candidate.
190
+ if (index < 0) return list.find((account) => !refused.has(account.id));
191
+ if (list.length < 2) return undefined;
192
+ for (let step = 1; step < list.length; step++) {
193
+ const candidate = list[(index + step) % list.length];
194
+ if (candidate !== undefined && !refused.has(candidate.id)) return candidate;
195
+ }
196
+ return undefined;
197
+ }
@@ -0,0 +1,36 @@
1
+ // Refreshing an account's OAuth token, serialized across pi processes: the provider rotates the
2
+ // refresh token on use, and the disk copy wins when another process already refreshed it.
3
+ import type { Credential, OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai";
4
+
5
+ import { withLock } from "./lock.ts";
6
+
7
+ const MARGIN_MS = 5 * 60 * 1000;
8
+
9
+ export function needsRefresh(credential: Credential | undefined): boolean {
10
+ return credential?.type === "oauth" && credential.expires <= Date.now() + MARGIN_MS;
11
+ }
12
+
13
+ /** Where a refresh reads the newest copy and writes its result. */
14
+ export interface Renewal {
15
+ freshen(id: string): Credential | undefined;
16
+ save(id: string, credential: Credential): void;
17
+ }
18
+
19
+ /** Refreshes under the shared lock, keeping what another pi already wrote. */
20
+ export async function refreshAccount(
21
+ oauth: OAuthAuth,
22
+ renewal: Renewal,
23
+ id: string,
24
+ credential: OAuthCredential,
25
+ signal: AbortSignal,
26
+ lockPath?: string,
27
+ ): Promise<OAuthCredential> {
28
+ const work = async (): Promise<OAuthCredential> => {
29
+ const onDisk = renewal.freshen(id);
30
+ if (onDisk?.type === "oauth" && !needsRefresh(onDisk)) return onDisk;
31
+ const fresh = await oauth.refresh(credential, signal);
32
+ renewal.save(id, fresh);
33
+ return fresh;
34
+ };
35
+ return lockPath === undefined ? await work() : await withLock(lockPath, work);
36
+ }
@@ -3,22 +3,21 @@
3
3
  import type { FeatureScope, Json, ScreenEntry } from "@adeildo/pi-kit";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
 
6
+ import {
7
+ type AccountsOf,
8
+ removeAccount,
9
+ removeQuestion,
10
+ renameAccount,
11
+ useAccount,
12
+ } from "./actions.ts";
6
13
  import { activeAccount } from "./active.ts";
7
- import { STATUS_KEY } from "./names.ts";
8
- import { pin, type Pins } from "./pins.ts";
14
+ import { kindText } from "./login.ts";
15
+ import type { Pins } from "./pins.ts";
9
16
  import type { AccountStore } from "./store.ts";
10
17
  import type { Account } from "./types.ts";
11
18
 
12
- /** The value of the choice that means pi's own credential. */
13
- export const DEFAULT_ACCOUNT = "default";
14
- export const DEFAULT_LABEL = "pi default";
15
-
16
19
  /** Everything a provider's rows share. The label is the section header. */
17
- interface ProviderGroup {
18
- pi: FeatureScope;
19
- store: AccountStore;
20
- pins: Pins;
21
- providerId: string;
20
+ interface ProviderGroup extends AccountsOf {
22
21
  label: string;
23
22
  }
24
23
 
@@ -28,61 +27,39 @@ export function accountRows(
28
27
  pins: Pins,
29
28
  ctx: ExtensionContext,
30
29
  ): ScreenEntry[] {
31
- const rows: ScreenEntry[] = [];
32
- for (const providerId of store.providerIds().toSorted()) {
33
- const group: ProviderGroup = {
34
- pi,
35
- store,
36
- pins,
37
- providerId,
38
- label: ctx.modelRegistry.getProviderDisplayName(providerId),
39
- };
40
- rows.push(
41
- defaultRow(group),
42
- ...store.accounts(providerId).flatMap((account) => accountBlock(group, account)),
43
- );
44
- }
45
- return rows;
46
- }
47
-
48
- /** Pi's own credential, as the first account of the provider. */
49
- function defaultRow(group: ProviderGroup): ScreenEntry {
50
- return {
51
- kind: "action",
52
- id: `accounts.${group.providerId}.default`,
53
- section: group.label,
54
- label: DEFAULT_LABEL,
55
- description: "The credential of /login, which stays in pi's own store.",
56
- text: () => (inUse(group) ? "login, in use" : "login"),
57
- indent: 1,
58
- run: (ctx) => {
59
- group.store.setActive(group.providerId, undefined);
60
- pin(group.pi, group.pins, group.providerId, null);
61
- refreshStatus(group.store, group.pins, ctx);
62
- return `${DEFAULT_LABEL} in use`;
63
- },
64
- };
30
+ return store
31
+ .providerIds()
32
+ .toSorted()
33
+ .flatMap((providerId) => {
34
+ const group: ProviderGroup = {
35
+ pi,
36
+ store,
37
+ pins,
38
+ providerId,
39
+ label: ctx.modelRegistry.getProviderDisplayName(providerId),
40
+ };
41
+ return store.accounts(providerId).flatMap((account) => accountBlock(group, account));
42
+ });
65
43
  }
66
44
 
67
45
  /** One account, and its actions, one level deeper. */
68
46
  function accountBlock(group: ProviderGroup, account: Account): ScreenEntry[] {
69
- const subscription = account.credential.type === "oauth";
47
+ const kind = kindText(account.credential.type);
48
+ const last = group.store.accounts(group.providerId).length === 1;
70
49
  return [
71
50
  {
72
51
  kind: "action",
73
52
  id: `accounts.${group.providerId}.${account.id}.use`,
74
53
  section: group.label,
75
54
  label: account.label,
76
- description: subscription ? "Saved from a subscription login." : "Saved as an API key.",
77
- text: () => {
78
- const kind = subscription ? "subscription" : "api key";
79
- return inUse(group, account) ? `${kind}, in use` : kind;
80
- },
55
+ description:
56
+ account.credential.type === "oauth"
57
+ ? "Saved from a subscription login."
58
+ : "Saved as an API key.",
59
+ text: () => (inUse(group, account) ? `${kind}, in use` : kind),
81
60
  indent: 1,
82
61
  run: (ctx) => {
83
- group.store.setActive(group.providerId, account.id);
84
- pin(group.pi, group.pins, group.providerId, account.id);
85
- refreshStatus(group.store, group.pins, ctx);
62
+ useAccount(group, ctx, account.id);
86
63
  return `${account.label} in use`;
87
64
  },
88
65
  },
@@ -97,9 +74,7 @@ function accountBlock(group: ProviderGroup, account: Account): ScreenEntry[] {
97
74
  get: () => account.label,
98
75
  set: (value: Json, ctx) => {
99
76
  if (typeof value !== "string") return "name it";
100
- const problem = group.store.rename(group.providerId, account.id, value);
101
- if (problem === undefined) refreshStatus(group.store, group.pins, ctx);
102
- return problem;
77
+ return renameAccount(group, ctx, account.id, value);
103
78
  },
104
79
  },
105
80
  {
@@ -109,39 +84,13 @@ function accountBlock(group: ProviderGroup, account: Account): ScreenEntry[] {
109
84
  label: "Remove",
110
85
  description: `Forget the "${account.label}" credential.`,
111
86
  text: () => account.label,
112
- confirm: `Remove the ${group.providerId} account "${account.label}"?`,
87
+ confirm: removeQuestion(group.label, account.label, last),
113
88
  indent: 2,
114
- run: (ctx) => {
115
- const problem = group.store.remove(group.providerId, account.id);
116
- if (problem !== undefined) return problem;
117
- if (group.pins.get(group.providerId) === account.id) {
118
- pin(group.pi, group.pins, group.providerId, null);
119
- }
120
- refreshStatus(group.store, group.pins, ctx);
121
- return `${account.label} removed`;
122
- },
89
+ run: (ctx) => removeAccount(group, ctx, account.id) ?? `${account.label} removed`,
123
90
  },
124
91
  ];
125
92
  }
126
93
 
127
- function inUse(group: ProviderGroup, account?: Account): boolean {
128
- const active = activeAccount(group.store, group.pins, group.providerId);
129
- return account === undefined ? active === undefined : active?.id === account.id;
130
- }
131
-
132
- /** The label of the account the current model uses, or undefined when there is nothing to say. */
133
- export function accountStatus(
134
- store: AccountStore,
135
- pins: Pins,
136
- ctx: ExtensionContext,
137
- ): string | undefined {
138
- const providerId = ctx.model?.provider;
139
- if (providerId === undefined) return undefined;
140
- if (store.accounts(providerId).length === 0) return undefined;
141
- return activeAccount(store, pins, providerId)?.label ?? "pi";
142
- }
143
-
144
- /** Publishes the account next to the model, where every other piece reads it. */
145
- export function refreshStatus(store: AccountStore, pins: Pins, ctx: ExtensionContext): void {
146
- ctx.ui.setStatus(STATUS_KEY, accountStatus(store, pins, ctx));
94
+ function inUse(group: ProviderGroup, account: Account): boolean {
95
+ return activeAccount(group.store, group.pins, group.providerId)?.id === account.id;
147
96
  }
@@ -0,0 +1,38 @@
1
+ import { literal, setting } from "@adeildo/pi-kit";
2
+
3
+ /** What a policy does when the provider will not serve an account. */
4
+ export const WHEN_LIMITED = ["ask", "switch", "stop"] as const;
5
+ export type WhenLimited = (typeof WHEN_LIMITED)[number];
6
+
7
+ function policy(id: string, label: string, description: string) {
8
+ return setting<WhenLimited>({
9
+ id,
10
+ default: "ask",
11
+ decoder: literal(...WHEN_LIMITED),
12
+ ui: {
13
+ section: "Accounts",
14
+ label,
15
+ description,
16
+ control: {
17
+ type: "choice",
18
+ options: [
19
+ { value: "ask", label: "ask me first" },
20
+ { value: "switch", label: "switch on its own" },
21
+ { value: "stop", label: "stop and tell me" },
22
+ ],
23
+ },
24
+ },
25
+ });
26
+ }
27
+
28
+ export const onLimit = policy(
29
+ "accounts.onLimit",
30
+ "When an account hits its limit",
31
+ "Ask me first, switch on its own, or stop and tell me.",
32
+ );
33
+
34
+ export const onAuthFailure = policy(
35
+ "accounts.onAuthFailure",
36
+ "When a login is refused",
37
+ "Ask me first, switch to another account, or stop and tell me.",
38
+ );
@@ -6,8 +6,9 @@ import { dirname, join } from "node:path";
6
6
  import { globalSettingsPath, isObject, readSettingsFile, writeSettingsFile } from "@adeildo/pi-kit";
7
7
  import type { Credential } from "@earendil-works/pi-ai";
8
8
 
9
+ import { FAILURE_KINDS, type FailureKind } from "../errors.ts";
9
10
  import { reason } from "./describe.ts";
10
- import type { Account, AccountsFile, ProviderAccounts } from "./types.ts";
11
+ import type { Account, AccountHealth, AccountsFile, ProviderAccounts } from "./types.ts";
11
12
 
12
13
  export const ACCOUNTS_FILE = "accounts.json";
13
14
 
@@ -15,6 +16,11 @@ export function accountsPath(): string {
15
16
  return join(dirname(globalSettingsPath()), ACCOUNTS_FILE);
16
17
  }
17
18
 
19
+ /** The file two pi processes take while they refresh the same accounts. */
20
+ export function accountsLockPath(): string {
21
+ return join(dirname(globalSettingsPath()), "accounts.lock");
22
+ }
23
+
18
24
  /** What an add produced: the account it wrote, or why it did not. */
19
25
  export interface Added {
20
26
  account?: Account;
@@ -78,7 +84,7 @@ export class AccountStore {
78
84
 
79
85
  rename(providerId: string, id: string, label: string): string | undefined {
80
86
  return this.edit((providers) => {
81
- const account = providers[providerId]?.accounts.find((entry) => entry.id === id);
87
+ const account = find(providers, providerId, id);
82
88
  if (account !== undefined) account.label = label.trim() || account.label;
83
89
  });
84
90
  }
@@ -96,11 +102,54 @@ export class AccountStore {
96
102
  /** Keeps the credential a refresh produced. */
97
103
  setCredential(providerId: string, id: string, credential: Credential): string | undefined {
98
104
  return this.edit((providers) => {
99
- const account = providers[providerId]?.accounts.find((entry) => entry.id === id);
105
+ const account = find(providers, providerId, id);
100
106
  if (account !== undefined) account.credential = credential;
101
107
  });
102
108
  }
103
109
 
110
+ /** Remembers the last refusal of an account, which the picker shows. */
111
+ markError(
112
+ providerId: string,
113
+ id: string,
114
+ kind: FailureKind,
115
+ message: string,
116
+ ): string | undefined {
117
+ return this.editHealth(providerId, id, (health) => {
118
+ health.lastError = { at: Date.now(), kind, message };
119
+ });
120
+ }
121
+
122
+ /** Forgets the last refusal, after a sign-in or a request that worked. */
123
+ clearError(providerId: string, id: string): string | undefined {
124
+ return this.editHealth(providerId, id, (health) => {
125
+ delete health.lastError;
126
+ });
127
+ }
128
+
129
+ /** Remembers that the plan is spent until a time, or forgets it with undefined. */
130
+ markLimited(providerId: string, id: string, until: number | undefined): string | undefined {
131
+ return this.editHealth(providerId, id, (health) => {
132
+ if (until === undefined) delete health.limitedUntil;
133
+ else health.limitedUntil = until;
134
+ });
135
+ }
136
+
137
+ private editHealth(
138
+ providerId: string,
139
+ id: string,
140
+ change: (health: AccountHealth) => void,
141
+ ): string | undefined {
142
+ return this.edit((providers) => {
143
+ const account = find(providers, providerId, id);
144
+ if (account === undefined) return;
145
+ const health = account.health ?? {};
146
+ change(health);
147
+ if (health.lastError === undefined && health.limitedUntil === undefined)
148
+ delete account.health;
149
+ else account.health = health;
150
+ });
151
+ }
152
+
104
153
  private file(): AccountsFile {
105
154
  if (this.data === undefined) this.reload();
106
155
  return this.data ?? { version: 1, providers: {} };
@@ -117,6 +166,14 @@ export class AccountStore {
117
166
  }
118
167
  }
119
168
 
169
+ function find(
170
+ providers: Record<string, ProviderAccounts>,
171
+ providerId: string,
172
+ id: string,
173
+ ): Account | undefined {
174
+ return providers[providerId]?.accounts.find((account) => account.id === id);
175
+ }
176
+
120
177
  function readAccounts(path: string): {
121
178
  providers: Record<string, ProviderAccounts>;
122
179
  warnings: string[];
@@ -130,7 +187,9 @@ function readAccounts(path: string): {
130
187
  const providers: Record<string, ProviderAccounts> = {};
131
188
  for (const [providerId, value] of Object.entries(source)) {
132
189
  if (!isObject(value) || !Array.isArray(value.accounts)) continue;
133
- const accounts = value.accounts.filter(isAccount);
190
+ const accounts = value.accounts
191
+ .map(asAccount)
192
+ .filter((account): account is Account => account !== undefined);
134
193
  if (accounts.length === 0) continue;
135
194
  providers[providerId] = {
136
195
  accounts,
@@ -149,13 +208,43 @@ function writeAccounts(path: string, data: AccountsFile): string | undefined {
149
208
  }
150
209
  }
151
210
 
152
- function isAccount(input: unknown): input is Account {
153
- return (
154
- isObject(input) &&
155
- typeof input.id === "string" &&
156
- typeof input.label === "string" &&
157
- isCredential(input.credential)
158
- );
211
+ function asAccount(input: unknown): Account | undefined {
212
+ if (!isObject(input)) return undefined;
213
+ if (
214
+ typeof input.id !== "string" ||
215
+ typeof input.label !== "string" ||
216
+ !isCredential(input.credential)
217
+ ) {
218
+ return undefined;
219
+ }
220
+ const health = readHealth(input.health);
221
+ return {
222
+ id: input.id,
223
+ label: input.label,
224
+ credential: input.credential,
225
+ ...(health === undefined ? {} : { health }),
226
+ };
227
+ }
228
+
229
+ /** Keeps the health fields that decode, so a hand edit cannot break the account. */
230
+ function readHealth(input: unknown): AccountHealth | undefined {
231
+ if (!isObject(input)) return undefined;
232
+ const health: AccountHealth = {};
233
+ const error = input.lastError;
234
+ if (
235
+ isObject(error) &&
236
+ typeof error.at === "number" &&
237
+ isFailureKind(error.kind) &&
238
+ typeof error.message === "string"
239
+ ) {
240
+ health.lastError = { at: error.at, kind: error.kind, message: error.message };
241
+ }
242
+ if (typeof input.limitedUntil === "number") health.limitedUntil = input.limitedUntil;
243
+ return health.lastError === undefined && health.limitedUntil === undefined ? undefined : health;
244
+ }
245
+
246
+ function isFailureKind(value: unknown): value is FailureKind {
247
+ return typeof value === "string" && (FAILURE_KINDS as readonly string[]).includes(value);
159
248
  }
160
249
 
161
250
  function isCredential(input: unknown): input is Credential {
@@ -1,16 +1,31 @@
1
1
  import type { Credential } from "@earendil-works/pi-ai";
2
2
 
3
- /** One extra credential with a name. Pi's own credential stays in pi's store, not here. */
3
+ import type { FailureKind } from "../errors.ts";
4
+
5
+ /** What an account's last failure was, and until when the plan says it is spent. */
6
+ export interface AccountHealth {
7
+ /** The last refusal, kept so the picker can say the account needs a sign-in. */
8
+ lastError?: {
9
+ at: number;
10
+ kind: FailureKind;
11
+ message: string;
12
+ };
13
+ /** The plan is spent until this unix second, when a reading said so. */
14
+ limitedUntil?: number;
15
+ }
16
+
17
+ /** One credential with a name. A provider with accounts serves every request from one of them. */
4
18
  export interface Account {
5
19
  /** Stable id, so renaming an account keeps a session pinned to it. */
6
20
  id: string;
7
21
  label: string;
8
22
  credential: Credential;
23
+ health?: AccountHealth;
9
24
  }
10
25
 
11
- /** The extra accounts of one provider, and the one the store prefers. */
26
+ /** The accounts of one provider, and the one a new session starts on. */
12
27
  export interface ProviderAccounts {
13
- /** The id of the account the store prefers. Absent means pi's own credential. */
28
+ /** The id of the account the store prefers. Absent means the first one. */
14
29
  active?: string;
15
30
  accounts: Account[];
16
31
  }