@adeildo/pi-providers 5.9.0 → 5.9.1

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.
package/README.md CHANGED
@@ -82,10 +82,6 @@ Several accounts per provider, with a switch to the next one when a limit hits,
82
82
 
83
83
  Part of [pi-harness](https://github.com/felipeadeildo/pi-harness), which brings every piece in one install.
84
84
 
85
- ## License
86
-
87
- MIT
88
-
89
85
  <p align="center">
90
86
  <a href="https://adeildo.dev"><img src="https://adeildo.dev/assets/images/cat.svg" width="42" height="42" alt="adeildo.dev"></a><br>
91
87
  Brought to you by <a href="https://adeildo.dev">adeildo.dev</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeildo/pi-providers",
3
- "version": "5.9.0",
3
+ "version": "5.9.1",
4
4
  "description": "Provider features for Pi. Bills Anthropic OAuth requests to the Claude Pro or Max plan instead of extra usage.",
5
5
  "keywords": [
6
6
  "anthropic",
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@adeildo/pi-kit": "5.9.0"
40
+ "@adeildo/pi-kit": "5.9.1"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@earendil-works/pi-ai": "*",
@@ -143,7 +143,10 @@ function accountStream(
143
143
  );
144
144
  }
145
145
 
146
- /** One account may serve a limit that another one does not have. A re-login costs an attempt too. */
146
+ /**
147
+ * A re-login or a move after an auth failure costs an attempt. A move after a limit does not: the
148
+ * account that refused leaves the candidates, so those moves end when the accounts do.
149
+ */
147
150
  const MAX_ATTEMPTS = 3;
148
151
 
149
152
  async function* attempts(
@@ -158,7 +161,7 @@ async function* attempts(
158
161
  ): AsyncGenerator<AssistantMessageEvent> {
159
162
  let account = first;
160
163
 
161
- for (let attempt = 1; ; attempt++) {
164
+ for (let attempt = 1; ;) {
162
165
  // Hold the events until something is visible: a retry on another account must not repeat a
163
166
  // message the caller already saw.
164
167
  const buffered: AssistantMessageEvent[] = [];
@@ -197,10 +200,12 @@ async function* attempts(
197
200
  if (failure === undefined && thrown === undefined && account !== undefined)
198
201
  session.served?.(account.id);
199
202
 
200
- if (!output && attempt < MAX_ATTEMPTS) {
203
+ const why = output ? undefined : failureOf(failure, thrown, model.provider);
204
+ if (why !== undefined && (why.kind === "usage" || attempt < MAX_ATTEMPTS)) {
201
205
  // oxlint-disable-next-line no-await-in-loop -- the answer comes from the user.
202
- const next = await moveOn(session, account?.id, failureOf(failure, thrown, model.provider));
206
+ const next = await moveOn(session, account?.id, why);
203
207
  if (next !== undefined) {
208
+ if (why.kind !== "usage") attempt++;
204
209
  account = next;
205
210
  continue;
206
211
  }
@@ -226,12 +231,12 @@ function failureOf(
226
231
  async function moveOn(
227
232
  session: AccountSession,
228
233
  accountId: string | undefined,
229
- failure: Failure | undefined,
234
+ failure: Failure,
230
235
  ): Promise<AccountCredential | undefined> {
231
236
  // Without an account there is nothing to move from, and the error is the provider's to show.
232
237
  if (accountId === undefined) return undefined;
233
- if (failure?.kind === "usage") return await session.afterLimit(accountId, failure.message);
234
- if (failure?.kind === "auth") return await session.afterAuthFailure?.(accountId, failure.message);
238
+ if (failure.kind === "usage") return await session.afterLimit(accountId, failure.message);
239
+ if (failure.kind === "auth") return await session.afterAuthFailure?.(accountId, failure.message);
235
240
  return undefined;
236
241
  }
237
242
 
@@ -5,6 +5,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  import { classifyFailure } from "../errors.ts";
7
7
  import { pick } from "../ui/picker.ts";
8
+ import { timeLeft } from "../usage/read.ts";
8
9
  import { refreshStatus } from "./actions.ts";
9
10
  import { reason } from "./describe.ts";
10
11
  import { loginFor } from "./interaction.ts";
@@ -43,23 +44,26 @@ export async function afterLimit(
43
44
  const mode = onLimit.get(scope);
44
45
  if (mode === "stop") return undefined;
45
46
 
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
- }
47
+ const candidates = limitCandidates(store.accounts(providerId), currentId, refused);
48
+ const [first] = candidates;
49
+ if (first === undefined) return undefined;
50
+ if (mode === "switch") return switchTo(scope, store, pins, ctx, providerId, first, detail);
61
51
 
62
- return switchTo(scope, store, pins, ctx, providerId, next, detail);
52
+ const current = store.accounts(providerId).find((entry) => entry.id === currentId);
53
+ const choices = new Map(candidates.map((account) => [switchLabel(account), account]));
54
+ const always = `Always switch on its own (now to ${first.label})`;
55
+ const picked = await pick(ctx, `"${current?.label ?? providerId}" hit its limit`, [
56
+ ...choices.keys(),
57
+ always,
58
+ "Stop here",
59
+ ]);
60
+ if (picked === always) {
61
+ scope.settings.set(onLimit, "switch");
62
+ return switchTo(scope, store, pins, ctx, providerId, first, detail);
63
+ }
64
+ const chosen = picked === undefined ? undefined : choices.get(picked);
65
+ if (chosen === undefined) return undefined;
66
+ return switchTo(scope, store, pins, ctx, providerId, chosen, detail);
63
67
  }
64
68
 
65
69
  /** Follows the `accounts.onAuthFailure` policy: sign in again, switch, or stop. */
@@ -178,6 +182,34 @@ function markLimited(
178
182
  return refused;
179
183
  }
180
184
 
185
+ /**
186
+ * Every other account that has not refused in this session, in the ring order after the current one.
187
+ * The ones a reading says are still spent go last, so a switch on its own does not land on them.
188
+ */
189
+ function limitCandidates(
190
+ list: readonly Account[],
191
+ currentId: string,
192
+ refused: ReadonlySet<string>,
193
+ ): Account[] {
194
+ const index = list.findIndex((account) => account.id === currentId);
195
+ const ring = index < 0 ? list : [...list.slice(index + 1), ...list.slice(0, index)];
196
+ const open = ring.filter((account) => !refused.has(account.id));
197
+ const ready = open.filter((account) => resetsIn(account) === undefined);
198
+ return [...ready, ...open.filter((account) => !ready.includes(account))];
199
+ }
200
+
201
+ /** How long until a spent account's plan resets, or undefined when it is not known to be spent. */
202
+ function resetsIn(account: Account): string | undefined {
203
+ return timeLeft(account.health?.limitedUntil ?? 0);
204
+ }
205
+
206
+ function switchLabel(account: Account): string {
207
+ const left = resetsIn(account);
208
+ return left === undefined
209
+ ? `Switch to ${account.label}`
210
+ : `Switch to ${account.label} (limited, resets in ${left})`;
211
+ }
212
+
181
213
  /** The account after this one that has not refused yet. */
182
214
  function nextAccount(
183
215
  list: readonly Account[],