@adeildo/pi-providers 5.8.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 +5 -0
- package/package.json +2 -2
- package/src/accounts/lift.ts +12 -7
- package/src/accounts/policy.ts +48 -16
- package/src/ui/accounts.ts +14 -22
- package/src/ui/login.ts +17 -23
- package/src/ui/picker.ts +15 -26
- package/src/ui/frame.ts +0 -27
package/README.md
CHANGED
|
@@ -81,3 +81,8 @@ The Claude plan billing comes from [pi-claude-max](https://github.com/bradennss/
|
|
|
81
81
|
Several accounts per provider, with a switch to the next one when a limit hits, follow [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider) by monotykamary, also MIT. This one keeps them inside pi's own login, with no OAuth of its own.
|
|
82
82
|
|
|
83
83
|
Part of [pi-harness](https://github.com/felipeadeildo/pi-harness), which brings every piece in one install.
|
|
84
|
+
|
|
85
|
+
<p align="center">
|
|
86
|
+
<a href="https://adeildo.dev"><img src="https://adeildo.dev/assets/images/cat.svg" width="42" height="42" alt="adeildo.dev"></a><br>
|
|
87
|
+
Brought to you by <a href="https://adeildo.dev">adeildo.dev</a>
|
|
88
|
+
</p>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-providers",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
40
|
+
"@adeildo/pi-kit": "5.9.1"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@earendil-works/pi-ai": "*",
|
package/src/accounts/lift.ts
CHANGED
|
@@ -143,7 +143,10 @@ function accountStream(
|
|
|
143
143
|
);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
/**
|
|
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; ;
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
234
|
-
if (failure
|
|
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
|
|
package/src/accounts/policy.ts
CHANGED
|
@@ -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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (mode === "
|
|
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
|
-
|
|
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[],
|
package/src/ui/accounts.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The accounts of one provider in our frame: which one is in use, what each plan has left, and the
|
|
2
2
|
// keys to switch, rename, remove or add one. Renaming and removing happen in place, so the list stays
|
|
3
3
|
// where it is and says what changed.
|
|
4
|
+
import { frame, frameInner, type FrameLine } from "@adeildo/pi-kit";
|
|
4
5
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import {
|
|
6
7
|
type Component,
|
|
@@ -15,7 +16,6 @@ import {
|
|
|
15
16
|
} from "@earendil-works/pi-tui";
|
|
16
17
|
|
|
17
18
|
import type { UsageWindow } from "../usage/types.ts";
|
|
18
|
-
import { frameBottom, frameInner, frameRow, frameTop } from "./frame.ts";
|
|
19
19
|
import { quotaWindows } from "./quota.ts";
|
|
20
20
|
|
|
21
21
|
/** What a row says past the account's name: its plan, or the trouble it is in. */
|
|
@@ -157,24 +157,25 @@ export class AccountsView implements Component, Focusable {
|
|
|
157
157
|
const states = new Map(rows.map((row) => [row.id, describeState(this.theme, row.state)]));
|
|
158
158
|
const columns = this.columns(rows, [...states.values()], inner);
|
|
159
159
|
|
|
160
|
-
const lines:
|
|
160
|
+
const lines: FrameLine[] = [];
|
|
161
161
|
if (rows.length === 0) lines.push(this.theme.fg("muted", this.listing.empty), "");
|
|
162
162
|
for (const item of items.slice(start, end)) {
|
|
163
163
|
const row = rows.find((candidate) => candidate.id === item);
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
const text =
|
|
165
|
+
row === undefined
|
|
166
|
+
? this.addLine()
|
|
167
|
+
: this.accountLine(row, columns, states.get(row.id) ?? "", inner);
|
|
168
|
+
lines.push({ text, selected: item === this.cursor });
|
|
166
169
|
}
|
|
167
|
-
lines.push("");
|
|
168
170
|
const status = this.statusLine(rows);
|
|
169
|
-
if (status !== undefined) lines.push(status);
|
|
171
|
+
if (status !== undefined) lines.push("", status);
|
|
170
172
|
const position = start > 0 || end < items.length ? `${at + 1}/${items.length}` : undefined;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
];
|
|
173
|
+
const row = rows.find((candidate) => candidate.id === this.cursor);
|
|
174
|
+
return frame(this.theme, width, lines, {
|
|
175
|
+
title: `${this.listing.provider} accounts`,
|
|
176
|
+
keys: this.keys(row),
|
|
177
|
+
footnote: position === undefined ? "" : this.theme.fg("dim", position),
|
|
178
|
+
});
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
private browsing(data: string): void {
|
|
@@ -308,15 +309,6 @@ export class AccountsView implements Component, Focusable {
|
|
|
308
309
|
return this.theme.fg(this.note.tone, this.note.text);
|
|
309
310
|
}
|
|
310
311
|
|
|
311
|
-
private hint(rows: readonly AccountRow[], position: string | undefined): string {
|
|
312
|
-
const row = rows.find((candidate) => candidate.id === this.cursor);
|
|
313
|
-
const text = this.keys(row).map(
|
|
314
|
-
([key, action]) => `${this.theme.fg("muted", key)} ${this.theme.fg("dim", action)}`,
|
|
315
|
-
);
|
|
316
|
-
if (position !== undefined) text.push(this.theme.fg("dim", position));
|
|
317
|
-
return text.join(" ");
|
|
318
|
-
}
|
|
319
|
-
|
|
320
312
|
/** The keys that do something right now, and what each one does. */
|
|
321
313
|
private keys(row: AccountRow | undefined): [string, string][] {
|
|
322
314
|
if (this.mode.kind === "rename") {
|
package/src/ui/login.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// it waits for. The provider drives it through the interaction pi would hand it.
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
|
|
5
|
+
import { frame, frameInner, type KeyHint } from "@adeildo/pi-kit";
|
|
5
6
|
import type { AuthEvent, AuthPrompt, ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
|
6
7
|
import {
|
|
7
8
|
copyToClipboard,
|
|
@@ -21,8 +22,6 @@ import {
|
|
|
21
22
|
truncateToWidth,
|
|
22
23
|
} from "@earendil-works/pi-tui";
|
|
23
24
|
|
|
24
|
-
import { frameBottom, frameInner, frameRow, frameTop } from "./frame.ts";
|
|
25
|
-
|
|
26
25
|
const MAX_LOG = 5;
|
|
27
26
|
|
|
28
27
|
/** Pi's own Text wraps the URL and the instructions into the rows the frame draws. */
|
|
@@ -139,12 +138,11 @@ export class LoginView implements Component, Focusable {
|
|
|
139
138
|
rows.push(this.linkHint());
|
|
140
139
|
}
|
|
141
140
|
if (this.code !== undefined) rows.push(this.theme.bold(this.code));
|
|
142
|
-
rows.push("", ...this.promptRows(inner)
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
];
|
|
141
|
+
rows.push("", ...this.promptRows(inner));
|
|
142
|
+
return frame(this.theme, width, rows, {
|
|
143
|
+
title: `Entrar · ${this.label}`,
|
|
144
|
+
keys: this.hint(),
|
|
145
|
+
});
|
|
148
146
|
}
|
|
149
147
|
|
|
150
148
|
/** Ends the screen; the login it was drawing ended one way or another. */
|
|
@@ -259,20 +257,16 @@ export class LoginView implements Component, Focusable {
|
|
|
259
257
|
return [asked, `${this.theme.fg("accent", "❯ ")}${field}`];
|
|
260
258
|
}
|
|
261
259
|
|
|
262
|
-
private hint():
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
];
|
|
274
|
-
return keys
|
|
275
|
-
.map(([key, action]) => `${this.theme.fg("muted", key)} ${this.theme.fg("dim", action)}`)
|
|
276
|
-
.join(" ");
|
|
260
|
+
private hint(): KeyHint[] {
|
|
261
|
+
return this.pending?.prompt.type === "select"
|
|
262
|
+
? [
|
|
263
|
+
["↑↓", "move"],
|
|
264
|
+
["enter", "choose"],
|
|
265
|
+
["esc", "cancel"],
|
|
266
|
+
]
|
|
267
|
+
: [
|
|
268
|
+
["enter", "submit"],
|
|
269
|
+
["esc", "cancel"],
|
|
270
|
+
];
|
|
277
271
|
}
|
|
278
272
|
}
|
package/src/ui/picker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Our dialogs wear the same frame as the rest of the harness. A host that cannot draw a component,
|
|
2
2
|
// like RPC, falls back to pi's own select.
|
|
3
|
-
import { oneAtATime } from "@adeildo/pi-kit";
|
|
3
|
+
import { frame, frameInner, type KeyHint, oneAtATime } from "@adeildo/pi-kit";
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import {
|
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
visibleWidth,
|
|
13
13
|
} from "@earendil-works/pi-tui";
|
|
14
14
|
|
|
15
|
-
import { frameBottom, frameInner, frameRow, frameTop } from "./frame.ts";
|
|
16
|
-
|
|
17
15
|
/** One row of a picker: a label, and a second column on the right. */
|
|
18
16
|
export interface PickOption {
|
|
19
17
|
label: string;
|
|
@@ -21,6 +19,11 @@ export interface PickOption {
|
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
const POINTER = "\u276f ";
|
|
22
|
+
const KEYS: KeyHint[] = [
|
|
23
|
+
["↑↓", "move"],
|
|
24
|
+
["enter", "select"],
|
|
25
|
+
["esc", "cancel"],
|
|
26
|
+
];
|
|
24
27
|
const MAX_VISIBLE = 10;
|
|
25
28
|
const GAP = 2;
|
|
26
29
|
const MIN_DESCRIPTION = 12;
|
|
@@ -109,15 +112,15 @@ export class FramedSelect implements Component {
|
|
|
109
112
|
start > 0 || end < this.options.length
|
|
110
113
|
? `${this.selected + 1}/${this.options.length}`
|
|
111
114
|
: undefined;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
this.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
const rows = this.options.slice(start, end).map((option, index) => ({
|
|
116
|
+
text: this.row(option, start + index, inner),
|
|
117
|
+
selected: start + index === this.selected,
|
|
118
|
+
}));
|
|
119
|
+
return frame(this.theme, width, rows, {
|
|
120
|
+
title: this.title,
|
|
121
|
+
keys: KEYS,
|
|
122
|
+
footnote: position === undefined ? "" : this.theme.fg("dim", position),
|
|
123
|
+
});
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
/** One line: the pointer, the label, and the description in a column shared by every row. */
|
|
@@ -161,18 +164,4 @@ export class FramedSelect implements Component {
|
|
|
161
164
|
const option = this.options[this.selected];
|
|
162
165
|
if (option !== undefined) this.done(labelOf(option));
|
|
163
166
|
}
|
|
164
|
-
|
|
165
|
-
private hint(position: string | undefined): string {
|
|
166
|
-
const keys = [
|
|
167
|
-
`${this.theme.fg("muted", "↑↓")} ${this.theme.fg("dim", "move")}`,
|
|
168
|
-
`${this.theme.fg("muted", "enter")} ${this.theme.fg("dim", "select")}`,
|
|
169
|
-
`${this.theme.fg("muted", "esc")} ${this.theme.fg("dim", "cancel")}`,
|
|
170
|
-
];
|
|
171
|
-
if (position !== undefined) keys.push(this.theme.fg("dim", position));
|
|
172
|
-
return keys.join(" ");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
private line(text: string, inner: number): string {
|
|
176
|
-
return frameRow(this.theme, text, inner);
|
|
177
|
-
}
|
|
178
167
|
}
|
package/src/ui/frame.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// The rounded frame every dialog of this package wears.
|
|
2
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
-
|
|
5
|
-
/** The columns a row's text may use, between the frame's sides and their padding. */
|
|
6
|
-
export function frameInner(width: number): number {
|
|
7
|
-
return Math.max(1, width - 4);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function frameTop(theme: Theme, title: string, width: number): string {
|
|
11
|
-
const border = (text: string) => theme.fg("border", text);
|
|
12
|
-
const room = Math.max(0, width - 5);
|
|
13
|
-
const label = theme.fg("accent", truncateToWidth(title, Math.max(0, room - 3), "..."));
|
|
14
|
-
const dashes = Math.max(0, room - visibleWidth(label));
|
|
15
|
-
return `${border("╭─ ")}${label}${border(` ${"─".repeat(dashes)}╮`)}`;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function frameRow(theme: Theme, text: string, inner: number): string {
|
|
19
|
-
const border = theme.fg("border", "│");
|
|
20
|
-
const clipped = truncateToWidth(text, inner);
|
|
21
|
-
const pad = " ".repeat(Math.max(0, inner - visibleWidth(clipped)));
|
|
22
|
-
return `${border} ${clipped}${pad} ${border}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function frameBottom(theme: Theme, width: number): string {
|
|
26
|
-
return theme.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
27
|
-
}
|