@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.
- package/README.md +47 -46
- package/package.json +2 -2
- package/src/accounts/actions.ts +94 -0
- package/src/accounts/active.ts +5 -6
- package/src/accounts/adopt.ts +70 -0
- package/src/accounts/attach.ts +195 -0
- package/src/accounts/dialogs.ts +332 -0
- package/src/accounts/feature.ts +99 -237
- package/src/accounts/interaction.ts +16 -4
- package/src/accounts/lift.ts +201 -49
- package/src/accounts/lock.ts +54 -0
- package/src/accounts/login.ts +14 -0
- package/src/accounts/pins.ts +22 -37
- package/src/accounts/policy.ts +197 -0
- package/src/accounts/refresh.ts +36 -0
- package/src/accounts/screen.ts +36 -87
- package/src/accounts/settings.ts +38 -0
- package/src/accounts/store.ts +100 -11
- package/src/accounts/types.ts +18 -3
- package/src/errors.ts +168 -0
- package/src/index.ts +2 -1
- package/src/ui/accounts.ts +400 -0
- package/src/ui/frame.ts +27 -0
- package/src/ui/login.ts +278 -0
- package/src/ui/picker.ts +161 -0
- package/src/ui/quota.ts +31 -0
- package/src/usage/anthropic.ts +150 -0
- package/src/usage/quota.ts +76 -0
- package/src/usage/read.ts +87 -0
- package/src/usage/types.ts +47 -0
package/README.md
CHANGED
|
@@ -1,82 +1,83 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">@adeildo/pi-providers</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/@adeildo/pi-providers"><img src="https://img.shields.io/npm/v/@adeildo/pi-providers" alt="npm"></a>
|
|
5
|
+
<a href="https://pi.dev"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffelipeadeildo%2Fpi-harness%2Fmain%2Fpackage.json&query=%24.devDependencies%5B%22%40earendil-works%2Fpi-coding-agent%22%5D&label=pi%20SDK&color=6E56CF" alt="pi SDK"></a>
|
|
6
|
+
</p>
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<strong>The plan you already pay for, and more than one login per provider.</strong><br>
|
|
10
|
+
Bill Anthropic requests to your Claude plan, and keep several accounts with a switch to the next one when a limit hits.
|
|
11
|
+
</p>
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
pi install npm:@adeildo/pi-providers
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the pieces.
|
|
13
|
+
<p align="center"><code>pi install npm:@adeildo/pi-providers</code></p>
|
|
12
14
|
|
|
13
|
-
##
|
|
15
|
+
## Claude plan
|
|
14
16
|
|
|
15
|
-
With a Claude Pro or Max plan connected through `/login anthropic`, Pi's requests count as
|
|
17
|
+
With a Claude Pro or Max plan connected through `/login anthropic`, Pi's requests count as extra usage, billed by the token. This makes them come out of the plan, the way Claude Code's own requests do.
|
|
16
18
|
|
|
17
19
|
> [!WARNING]
|
|
18
|
-
>
|
|
20
|
+
> It does that by introducing Pi to Anthropic as Claude Code, and using a subscription from anything other than Claude Code may break Anthropic's terms. It is on as soon as you install the package. To say no to that: switch the extension off in `pi config`, or set `"features": { "subscription": { "enabled": false } }` in the settings file below.
|
|
19
21
|
|
|
20
|
-
Pi already sends Claude Code's identity
|
|
22
|
+
Pi already sends Claude Code's identity when the token is an OAuth one. Anthropic checks two more things before it bills the request to the plan:
|
|
21
23
|
|
|
22
24
|
1. The user agent has to read `claude-cli/<version> (external, cli)`.
|
|
23
|
-
2. The first `system` block has to be
|
|
25
|
+
2. The first `system` block has to be the billing attribution, with nothing Claude Code would not send. Pi's own prompt moves to the start of the first user message, wrapped in `<system-instructions>`, where the model still reads it.
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
Any provider that speaks the Anthropic Messages API with an OAuth token gets this, not only the one named `anthropic`, so a second account under another name is billed to its own plan. A request with an API key goes out untouched.
|
|
26
28
|
|
|
27
|
-
Pi warns about extra usage
|
|
29
|
+
Pi warns about extra usage in every Anthropic OAuth session. Once the plan pays, that warning is wrong, and `warnings.anthropicExtraUsage` in `~/.pi/agent/settings.json` turns it off.
|
|
28
30
|
|
|
29
31
|
```json
|
|
30
32
|
{ "warnings": { "anthropicExtraUsage": false } }
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
```json
|
|
36
|
-
{ "subscription": { "claudeCodeVersion": "2.1.280" } }
|
|
37
|
-
```
|
|
35
|
+
Anthropic answers `400 claude_code_version_too_old` when it considers the version too old for a newer model, and `subscription.claudeCodeVersion` raises the version Pi reports.
|
|
38
36
|
|
|
39
|
-
##
|
|
37
|
+
## Accounts
|
|
40
38
|
|
|
41
|
-
Pi keeps one credential per provider, the one `/login` wrote.
|
|
39
|
+
Pi keeps one credential per provider, the one `/login` wrote. This keeps several, each with a name. Once a provider has an account, its accounts are all it has: every request, refresh and plan reading comes from the account in use, and there is no unnamed credential behind them.
|
|
42
40
|
|
|
43
41
|
```text
|
|
44
|
-
/accounts
|
|
42
|
+
/accounts the accounts of the current model's provider
|
|
43
|
+
/accounts anthropic the accounts of another one
|
|
45
44
|
```
|
|
46
45
|
|
|
47
|
-
|
|
46
|
+
The list shows each account, the one in use and what its plan has left, and fills the plans in as they arrive. `enter` uses an account, here and in the sessions that start after, `r` renames it in place, `d` removes it after asking, and `a` or the last row adds one. Adding asks for a name and runs the login Pi already has, so there is no OAuth of its own here. The first account of a provider brings Pi's own login along under a name you choose, since from then on only accounts are read.
|
|
47
|
+
|
|
48
|
+
A `/login` for a provider that already has accounts asks where the credential goes, replacing an account or becoming a new one, and closing that dialog still keeps it as a new account under the provider's name. Pi keeps its own copy too, because it decides which providers are logged in before any extension starts; that copy is renewed from an account and never read for a request. Each account shows its kind:
|
|
48
49
|
|
|
49
|
-
| Kind | Is
|
|
50
|
-
| ------- |
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `key` | An API key |
|
|
50
|
+
| Kind | Is |
|
|
51
|
+
| ------- | -------------------------------------------- |
|
|
52
|
+
| `oauth` | A subscription token, billed to its own plan |
|
|
53
|
+
| `key` | An API key |
|
|
54
54
|
|
|
55
|
-
`alt+a` switches the account of the current model's provider, and the Providers tab of `Alt+S` lists
|
|
55
|
+
`alt+a` switches the account of the current model's provider for this session, and the Providers tab of `Alt+S` lists them too. The account in use sits next to the model, and its plan's windows below the editor, read again whenever a model of another provider is picked. An account whose credential the provider refused says `sign in again`, and `enter` on it in `/accounts` signs in again under the same name. The session remembers the choice, so a resume or a fork comes back to the same one.
|
|
56
56
|
|
|
57
|
-
When a
|
|
57
|
+
When a provider refuses a request for a quota or a rate limit and that provider has another account, `accounts.onLimit` decides what happens:
|
|
58
58
|
|
|
59
|
-
| Value | Does
|
|
60
|
-
| -------- |
|
|
61
|
-
| `ask` | Opens a dialog, with
|
|
62
|
-
| `switch` | Moves to the next account on its own
|
|
63
|
-
| `stop` | Leaves the error in front of you
|
|
59
|
+
| Value | Does |
|
|
60
|
+
| -------- | ------------------------------------------------------------------------- |
|
|
61
|
+
| `ask` | Opens a dialog, with an option to always switch from then on. The default |
|
|
62
|
+
| `switch` | Moves to the next account on its own |
|
|
63
|
+
| `stop` | Leaves the error in front of you |
|
|
64
64
|
|
|
65
|
-
Only a limit that arrives before anything was shown switches, so
|
|
65
|
+
Only a limit that arrives before anything was shown switches, so a retry never repeats output you already saw. Only chat streams move: image generation, classifier calls and deferred responses use the account in use, and surface their errors as they are.
|
|
66
|
+
|
|
67
|
+
A quota belongs to the account, so an account that refused is skipped until it answers again, and the same dialog is not asked twice in a session. A credential the provider refuses instead opens `accounts.onAuthFailure`, whose dialog can run the provider's own login again under the same account, keeping its name and the session pin.
|
|
66
68
|
|
|
67
69
|
## Settings
|
|
68
70
|
|
|
69
71
|
In `~/.pi/agent/extensions/pi-harness/settings.json`:
|
|
70
72
|
|
|
71
|
-
| Key | Default | Does
|
|
72
|
-
| -------------------------------- | ----------- |
|
|
73
|
-
| `subscription.claudeCodeVersion` | `"2.1.280"` | The
|
|
74
|
-
| `accounts.onLimit` | `"ask"` | What a quota or rate limit does when another account exists: `"ask"`, `"switch"` or `"stop"`
|
|
75
|
-
|
|
76
|
-
## Credit
|
|
73
|
+
| Key | Default | Does |
|
|
74
|
+
| -------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `subscription.claudeCodeVersion` | `"2.1.280"` | The version Pi reports. Anthropic will not serve newer models to a version it considers old |
|
|
76
|
+
| `accounts.onLimit` | `"ask"` | What a quota or a rate limit does when another account exists: `"ask"`, `"switch"` or `"stop"` |
|
|
77
|
+
| `accounts.onAuthFailure` | `"ask"` | What a refused credential does: `"ask"` (sign in again or switch), `"switch"` or `"stop"` |
|
|
77
78
|
|
|
78
|
-
The
|
|
79
|
+
The Claude plan billing comes from [pi-claude-max](https://github.com/bradennss/pi-claude-max) by Braden Lamb, MIT. This is a rewrite: it reads the version from settings, carries pi's cache breakpoints over instead of dropping the one-hour TTL, moves every system block after the identity instead of only the second, and checks for an OAuth token instead of the provider name.
|
|
79
80
|
|
|
80
|
-
|
|
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.
|
|
81
82
|
|
|
82
|
-
[
|
|
83
|
+
Part of [pi-harness](https://github.com/felipeadeildo/pi-harness), which brings every piece in one install.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-providers",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
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.3.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@earendil-works/pi-ai": "*",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// What can be done to an account from any dialog, and the status that follows it: the settings screen,
|
|
2
|
+
// /accounts and a login's adoption all go through here.
|
|
3
|
+
import type { Credential } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { activeAccount } from "./active.ts";
|
|
7
|
+
import { NAME, STATUS_KEY } from "./names.ts";
|
|
8
|
+
import { pin, type Pins } from "./pins.ts";
|
|
9
|
+
import type { AccountStore } from "./store.ts";
|
|
10
|
+
import type { Account } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/** One provider's accounts, as the actions reach them. */
|
|
13
|
+
export interface AccountsOf {
|
|
14
|
+
pi: ExtensionAPI;
|
|
15
|
+
store: AccountStore;
|
|
16
|
+
pins: Pins;
|
|
17
|
+
providerId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Asks for a name, keeping the suggestion when the answer is empty. */
|
|
21
|
+
export async function askName(
|
|
22
|
+
ctx: ExtensionContext,
|
|
23
|
+
title: string,
|
|
24
|
+
suggestion: string,
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
return (await ctx.ui.input(title, suggestion))?.trim() || suggestion;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stores a new account, saying why when it could not. */
|
|
30
|
+
export function saveAccount(
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
store: AccountStore,
|
|
33
|
+
providerId: string,
|
|
34
|
+
label: string,
|
|
35
|
+
credential: Credential,
|
|
36
|
+
): Account | undefined {
|
|
37
|
+
const added = store.add(providerId, label, credential);
|
|
38
|
+
if (added.account === undefined) ctx.ui.notify(`${NAME}: ${added.problem}`, "error");
|
|
39
|
+
return added.account;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Makes an account the one in use, in this session and in the ones that start after it. */
|
|
43
|
+
export function useAccount(of: AccountsOf, ctx: ExtensionContext, accountId: string): void {
|
|
44
|
+
of.store.setActive(of.providerId, accountId);
|
|
45
|
+
pin(of.pi, of.pins, of.providerId, accountId);
|
|
46
|
+
refreshStatus(of.store, of.pins, ctx);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Renames an account, answering with a problem or undefined. */
|
|
50
|
+
export function renameAccount(
|
|
51
|
+
of: AccountsOf,
|
|
52
|
+
ctx: ExtensionContext,
|
|
53
|
+
accountId: string,
|
|
54
|
+
label: string,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
const problem = of.store.rename(of.providerId, accountId, label);
|
|
57
|
+
if (problem === undefined) refreshStatus(of.store, of.pins, ctx);
|
|
58
|
+
return problem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Removes an account; a session pinned to it goes back to the store's choice. */
|
|
62
|
+
export function removeAccount(
|
|
63
|
+
of: AccountsOf,
|
|
64
|
+
ctx: ExtensionContext,
|
|
65
|
+
accountId: string,
|
|
66
|
+
): string | undefined {
|
|
67
|
+
const problem = of.store.remove(of.providerId, accountId);
|
|
68
|
+
if (problem !== undefined) return problem;
|
|
69
|
+
if (of.pins.get(of.providerId) === accountId) of.pins.delete(of.providerId);
|
|
70
|
+
refreshStatus(of.store, of.pins, ctx);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What removing an account asks, saying so when it is the last one the provider has. */
|
|
75
|
+
export function removeQuestion(provider: string, label: string, last: boolean): string {
|
|
76
|
+
if (!last) return `Remove the ${provider} account "${label}"?`;
|
|
77
|
+
return `Remove "${label}"? It is the last ${provider} account, so ${provider} goes back to pi's own /login.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The label of the account the current model uses, or undefined when there is nothing to say. */
|
|
81
|
+
export function accountStatus(
|
|
82
|
+
store: AccountStore,
|
|
83
|
+
pins: Pins,
|
|
84
|
+
ctx: ExtensionContext,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
const providerId = ctx.model?.provider;
|
|
87
|
+
if (providerId === undefined) return undefined;
|
|
88
|
+
return activeAccount(store, pins, providerId)?.label;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Publishes the account next to the model, where every other piece reads it. */
|
|
92
|
+
export function refreshStatus(store: AccountStore, pins: Pins, ctx: ExtensionContext): void {
|
|
93
|
+
ctx.ui.setStatus(STATUS_KEY, accountStatus(store, pins, ctx));
|
|
94
|
+
}
|
package/src/accounts/active.ts
CHANGED
|
@@ -4,16 +4,15 @@ import type { AccountStore } from "./store.ts";
|
|
|
4
4
|
import type { Account } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The account a request uses: the session pin, else the store's choice, else
|
|
8
|
-
*
|
|
9
|
-
* over a session by itself.
|
|
7
|
+
* The account a request uses: the session pin, else the store's choice, else the first account. A
|
|
8
|
+
* provider with accounts always has one in use; undefined means it has none.
|
|
10
9
|
*/
|
|
11
10
|
export function activeAccount(
|
|
12
11
|
store: AccountStore,
|
|
13
12
|
pins: Pins,
|
|
14
13
|
providerId: string,
|
|
15
14
|
): Account | undefined {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
return
|
|
15
|
+
const accounts = store.accounts(providerId);
|
|
16
|
+
const wanted = pins.get(providerId) ?? store.active(providerId)?.id;
|
|
17
|
+
return accounts.find((account) => account.id === wanted) ?? accounts[0];
|
|
19
18
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// What happens after a provider login: the credential gets a name, either replacing an account or
|
|
2
|
+
// becoming a new one, and the session moves to it.
|
|
3
|
+
import type { FeatureScope } from "@adeildo/pi-kit";
|
|
4
|
+
import type { Credential } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { pick } from "../ui/picker.ts";
|
|
8
|
+
import { type AccountsOf, askName, saveAccount, useAccount } from "./actions.ts";
|
|
9
|
+
import { activeAccount } from "./active.ts";
|
|
10
|
+
import { NAME } from "./names.ts";
|
|
11
|
+
import type { Pins } from "./pins.ts";
|
|
12
|
+
import type { AccountStore } from "./store.ts";
|
|
13
|
+
import type { Account } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pi's login just produced a credential; ask where it belongs and keep it. Pi's own copy is no
|
|
17
|
+
* account, so a login that went nowhere would be lost: closing the dialog keeps it as a new account
|
|
18
|
+
* under the provider's name, which `/accounts` can rename or remove.
|
|
19
|
+
*/
|
|
20
|
+
export async function adoptAccount(
|
|
21
|
+
scope: FeatureScope,
|
|
22
|
+
store: AccountStore,
|
|
23
|
+
pins: Pins,
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
providerId: string,
|
|
26
|
+
credential: Credential,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const provider = ctx.modelRegistry.getProviderDisplayName(providerId);
|
|
29
|
+
const of: AccountsOf = { pi: scope, store, pins, providerId };
|
|
30
|
+
const current = activeAccount(store, pins, providerId)?.id;
|
|
31
|
+
const choices: { account: Account | undefined; label: string }[] = store
|
|
32
|
+
.accounts(providerId)
|
|
33
|
+
.map((account) => ({
|
|
34
|
+
account,
|
|
35
|
+
label: `Replace ${account.label}${account.id === current ? " (in use)" : ""}`,
|
|
36
|
+
}));
|
|
37
|
+
choices.push({ account: undefined, label: "New account" });
|
|
38
|
+
|
|
39
|
+
const picked = ctx.hasUI
|
|
40
|
+
? await pick(
|
|
41
|
+
ctx,
|
|
42
|
+
`Keep this ${provider} login as`,
|
|
43
|
+
choices.map((choice) => choice.label),
|
|
44
|
+
)
|
|
45
|
+
: undefined;
|
|
46
|
+
const choice = choices.find((candidate) => candidate.label === picked);
|
|
47
|
+
|
|
48
|
+
if (choice?.account !== undefined) {
|
|
49
|
+
const problem = store.setCredential(providerId, choice.account.id, credential);
|
|
50
|
+
if (problem !== undefined) {
|
|
51
|
+
ctx.ui.notify(`${NAME}: ${problem}`, "error");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
store.clearError(providerId, choice.account.id);
|
|
55
|
+
keep(of, ctx, provider, choice.account);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A closed dialog asks nothing more, and the login keeps the provider's name.
|
|
60
|
+
let label = provider;
|
|
61
|
+
if (choice !== undefined) label = await askName(ctx, `Name this ${provider} account`, provider);
|
|
62
|
+
const added = saveAccount(ctx, store, providerId, label, credential);
|
|
63
|
+
if (added !== undefined) keep(of, ctx, provider, added);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Moves the session to the account the login belongs to. */
|
|
67
|
+
function keep(of: AccountsOf, ctx: ExtensionContext, provider: string, account: Account): void {
|
|
68
|
+
useAccount(of, ctx, account.id);
|
|
69
|
+
ctx.ui.notify(`${NAME}: ${provider} now uses ${account.label}`, "info");
|
|
70
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Joining a provider to the session: which account a request uses, where a refreshed token goes, and
|
|
2
|
+
// what quota each account reported.
|
|
3
|
+
import type { FeatureScope } from "@adeildo/pi-kit";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { loginScreen as openLogin } from "../ui/login.ts";
|
|
7
|
+
import type { Quota } from "../usage/quota.ts";
|
|
8
|
+
import { noteUsage, resetNote, usageOf, worstWindow } from "../usage/read.ts";
|
|
9
|
+
import type { AccountUsage, UsageMap } from "../usage/types.ts";
|
|
10
|
+
import { refreshStatus } from "./actions.ts";
|
|
11
|
+
import { activeAccount } from "./active.ts";
|
|
12
|
+
import { adoptAccount } from "./adopt.ts";
|
|
13
|
+
import { reason } from "./describe.ts";
|
|
14
|
+
import { type AccountCredential, liftProvider, nativeOf, type AccountSession } from "./lift.ts";
|
|
15
|
+
import type { Pins } from "./pins.ts";
|
|
16
|
+
import { afterAuthFailure, afterLimit } from "./policy.ts";
|
|
17
|
+
import { type Renewal, needsRefresh, refreshAccount } from "./refresh.ts";
|
|
18
|
+
import { accountsLockPath, type AccountStore } from "./store.ts";
|
|
19
|
+
import type { Account } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/** What the session learns while it runs. */
|
|
22
|
+
export interface Watch {
|
|
23
|
+
limited: Map<string, Set<string>>;
|
|
24
|
+
usage: UsageMap;
|
|
25
|
+
quota: Quota;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function attachProvider(
|
|
29
|
+
scope: FeatureScope,
|
|
30
|
+
store: AccountStore,
|
|
31
|
+
pins: Pins,
|
|
32
|
+
watch: Watch,
|
|
33
|
+
ctx: ExtensionContext,
|
|
34
|
+
providerId: string,
|
|
35
|
+
): void {
|
|
36
|
+
// A pin with no account left behind it injects nothing, so re-registering would only cost a
|
|
37
|
+
// model-registry rebuild.
|
|
38
|
+
if (!store.has(providerId)) return;
|
|
39
|
+
|
|
40
|
+
// `getProvider` returns the lifted provider once we registered one, so unwrap it before wrapping
|
|
41
|
+
// again. After a `/reload` it returns the built-in one, which is unwrapped too.
|
|
42
|
+
const provider = ctx.modelRegistry.getProvider(providerId);
|
|
43
|
+
if (provider === undefined) {
|
|
44
|
+
scope.warn(`no provider "${providerId}", so its accounts do nothing`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
scope.registerProvider(
|
|
48
|
+
liftProvider(nativeOf(provider), sessionFor(scope, store, pins, watch, ctx, providerId)),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sessionFor(
|
|
53
|
+
scope: FeatureScope,
|
|
54
|
+
store: AccountStore,
|
|
55
|
+
pins: Pins,
|
|
56
|
+
watch: Watch,
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
providerId: string,
|
|
59
|
+
): AccountSession {
|
|
60
|
+
return {
|
|
61
|
+
resolve: () => requestOf(activeAccount(store, pins, providerId)),
|
|
62
|
+
subscription: () =>
|
|
63
|
+
requestOf(store.accounts(providerId).find((account) => account.credential.type === "oauth")),
|
|
64
|
+
save: (id, credential) => void store.setCredential(providerId, id, credential),
|
|
65
|
+
served: (id) => {
|
|
66
|
+
watch.limited.get(providerId)?.delete(id);
|
|
67
|
+
store.clearError(providerId, id);
|
|
68
|
+
store.markLimited(providerId, id, undefined);
|
|
69
|
+
},
|
|
70
|
+
adopt: (credential) => adoptAccount(scope, store, pins, ctx, providerId, credential),
|
|
71
|
+
loginScreen: (label, signal) => openLogin(ctx, label, signal),
|
|
72
|
+
freshen: (id) => renewalFor(store, providerId).freshen(id),
|
|
73
|
+
lockPath: accountsLockPath,
|
|
74
|
+
noteUsage: (id, headers) =>
|
|
75
|
+
noteUsage(watch.usage, providerId, id, watch.quota.fromHeaders(providerId, headers)),
|
|
76
|
+
afterLimit: async (currentId, detail) => {
|
|
77
|
+
const reading = await readingFor(watch, store, ctx, providerId, currentId);
|
|
78
|
+
const until = worstWindow(reading)?.window.resetsAt;
|
|
79
|
+
if (until !== undefined) store.markLimited(providerId, currentId, until);
|
|
80
|
+
return await afterLimit(
|
|
81
|
+
scope,
|
|
82
|
+
store,
|
|
83
|
+
pins,
|
|
84
|
+
ctx,
|
|
85
|
+
providerId,
|
|
86
|
+
currentId,
|
|
87
|
+
withReset(detail, reading),
|
|
88
|
+
watch.limited,
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
afterAuthFailure: (currentId, detail) =>
|
|
92
|
+
afterAuthFailure(scope, store, pins, ctx, providerId, currentId, detail),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** An account as a request carries it. */
|
|
97
|
+
function requestOf(account: Account | undefined): AccountCredential | undefined {
|
|
98
|
+
return account === undefined ? undefined : { id: account.id, credential: account.credential };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function withReset(detail: string, usage: AccountUsage | undefined): string {
|
|
102
|
+
const note = resetNote(usage);
|
|
103
|
+
return note === undefined ? detail : `${detail} · ${note}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The reading already known: the live one, else the endpoint's. */
|
|
107
|
+
export function knownReading(
|
|
108
|
+
watch: Watch,
|
|
109
|
+
providerId: string,
|
|
110
|
+
accountId: string,
|
|
111
|
+
): AccountUsage | undefined {
|
|
112
|
+
return usageOf(watch.usage, providerId, accountId) ?? watch.quota.read(providerId, accountId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Reads every account's plan of a provider, so the footer and the dialogs find their quota ready. */
|
|
116
|
+
export async function readPlans(
|
|
117
|
+
store: AccountStore,
|
|
118
|
+
watch: Watch,
|
|
119
|
+
ctx: ExtensionContext,
|
|
120
|
+
providerId: string | undefined,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (!ctx.hasUI || providerId === undefined || !store.has(providerId)) return;
|
|
123
|
+
await Promise.all(
|
|
124
|
+
store
|
|
125
|
+
.accounts(providerId)
|
|
126
|
+
.map((account) => readingFor(watch, store, ctx, providerId, account.id)),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Reads a provider's plans in the background, and draws the account again once they are in. */
|
|
131
|
+
export function showPlans(
|
|
132
|
+
store: AccountStore,
|
|
133
|
+
pins: Pins,
|
|
134
|
+
watch: Watch,
|
|
135
|
+
ctx: ExtensionContext,
|
|
136
|
+
providerId: string | undefined,
|
|
137
|
+
): void {
|
|
138
|
+
void readPlans(store, watch, ctx, providerId).then(() => refreshStatus(store, pins, ctx));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The best reading for an account: the live one, else the endpoint's, fetched when there is none. */
|
|
142
|
+
export async function readingFor(
|
|
143
|
+
watch: Watch,
|
|
144
|
+
store: AccountStore,
|
|
145
|
+
ctx: ExtensionContext,
|
|
146
|
+
providerId: string,
|
|
147
|
+
accountId: string,
|
|
148
|
+
): Promise<AccountUsage | undefined> {
|
|
149
|
+
const known = knownReading(watch, providerId, accountId);
|
|
150
|
+
if (known !== undefined) return known;
|
|
151
|
+
const token = await planToken(store, ctx, providerId, accountId);
|
|
152
|
+
return await watch.quota.ensure(providerId, accountId, token);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The token that can read a plan, refreshed when it has expired. */
|
|
156
|
+
async function planToken(
|
|
157
|
+
store: AccountStore,
|
|
158
|
+
ctx: ExtensionContext,
|
|
159
|
+
providerId: string,
|
|
160
|
+
accountId: string,
|
|
161
|
+
): Promise<string | undefined> {
|
|
162
|
+
const account = store.accounts(providerId).find((entry) => entry.id === accountId);
|
|
163
|
+
const credential = account?.credential;
|
|
164
|
+
if (account === undefined || credential?.type !== "oauth") return undefined;
|
|
165
|
+
if (!needsRefresh(credential)) return credential.access;
|
|
166
|
+
|
|
167
|
+
const registered = ctx.modelRegistry.getProvider(providerId);
|
|
168
|
+
const oauth = registered === undefined ? undefined : nativeOf(registered).auth.oauth;
|
|
169
|
+
if (oauth === undefined) return undefined;
|
|
170
|
+
try {
|
|
171
|
+
const fresh = await refreshAccount(
|
|
172
|
+
oauth,
|
|
173
|
+
renewalFor(store, providerId),
|
|
174
|
+
account.id,
|
|
175
|
+
credential,
|
|
176
|
+
new AbortController().signal,
|
|
177
|
+
accountsLockPath(),
|
|
178
|
+
);
|
|
179
|
+
return fresh.access;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
store.markError(providerId, account.id, "auth", reason(error));
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The store side of a refresh: the newest copy, and where to save the result. */
|
|
187
|
+
function renewalFor(store: AccountStore, providerId: string): Renewal {
|
|
188
|
+
return {
|
|
189
|
+
freshen: (id) => {
|
|
190
|
+
store.reload();
|
|
191
|
+
return store.accounts(providerId).find((account) => account.id === id)?.credential;
|
|
192
|
+
},
|
|
193
|
+
save: (id, credential) => void store.setCredential(providerId, id, credential),
|
|
194
|
+
};
|
|
195
|
+
}
|