@adeildo/pi-providers 4.0.0 → 5.0.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 +27 -1
- package/package.json +4 -3
- package/src/accounts/active.ts +19 -0
- package/src/accounts/describe.ts +3 -0
- package/src/accounts/feature.ts +279 -0
- package/src/accounts/interaction.ts +60 -0
- package/src/accounts/lift.ts +331 -0
- package/src/accounts/login.ts +41 -0
- package/src/accounts/names.ts +11 -0
- package/src/accounts/pins.ts +58 -0
- package/src/accounts/screen.ts +147 -0
- package/src/accounts/store.ts +167 -0
- package/src/accounts/types.ts +21 -0
- package/src/index.ts +5 -1
- package/src/subscription/billing.ts +0 -3
- package/src/subscription/feature.ts +2 -4
- package/src/subscription/payload.ts +1 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@adeildo/pi-providers)
|
|
4
4
|
|
|
5
|
-
Provider features for [Pi](https://pi.dev). It has
|
|
5
|
+
Provider features for [Pi](https://pi.dev). It has two, `subscription` and `accounts`.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
pi install npm:@adeildo/pi-providers
|
|
@@ -37,6 +37,7 @@ In `~/.pi/agent/extensions/pi-harness/settings.json`:
|
|
|
37
37
|
| Key | Default | Does |
|
|
38
38
|
| -------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------- |
|
|
39
39
|
| `subscription.claudeCodeVersion` | `"2.1.280"` | The Claude Code version Pi reports. Anthropic won't serve newer models to versions it considers too old |
|
|
40
|
+
| `accounts.onLimit` | `"ask"` | What a quota or rate limit does when another account exists: `"ask"`, `"switch"`, or `"stop"` |
|
|
40
41
|
|
|
41
42
|
When a request comes back with `400 claude_code_version_too_old`, raise the version:
|
|
42
43
|
|
|
@@ -44,6 +45,31 @@ When a request comes back with `400 claude_code_version_too_old`, raise the vers
|
|
|
44
45
|
{ "subscription": { "claudeCodeVersion": "2.1.280" } }
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
## accounts
|
|
49
|
+
|
|
50
|
+
Pi keeps one credential per provider: the one `/login` wrote. `accounts` keeps more. The credential of `/login` stays as `pi default`, and every other account is an extra credential with a name.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
/accounts anthropic
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Picks the provider and its login method, asks for a name, and runs the login Pi already has, so no OAuth lives in this package. An account shows a short kind:
|
|
57
|
+
|
|
58
|
+
- `login` is the credential of `/login`, which cannot be copied out of `auth.json`.
|
|
59
|
+
- `oauth` is a subscription token, billed to its own plan.
|
|
60
|
+
- `key` is an API key.
|
|
61
|
+
|
|
62
|
+
Switch the account:
|
|
63
|
+
|
|
64
|
+
- `alt+a` picks the account of the current model's provider.
|
|
65
|
+
- The Providers tab of `Alt+S` lists the accounts: choose the one to use, rename one, or remove one.
|
|
66
|
+
|
|
67
|
+
When a request is refused for a quota or a rate limit, and another account of that provider exists, `accounts.onLimit` decides what happens: `ask` opens a dialog, with the option to always switch from then on; `switch` moves on its own; `stop` leaves the error in front of you. Only a limit that arrives before anything was shown switches, so the retry never repeats output you already saw.
|
|
68
|
+
|
|
69
|
+
The account in use shows next to the model. The session remembers its choice, so a resume or a fork comes back with the same one, and `pi default` is what a request uses until you choose another.
|
|
70
|
+
|
|
71
|
+
Only the chat streams are lifted. Image generation, classifier calls and deferred responses of the provider still use pi's own credential.
|
|
72
|
+
|
|
47
73
|
## Credit
|
|
48
74
|
|
|
49
75
|
The idea and the fingerprint come 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-providers",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.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,11 +37,12 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@adeildo/pi-kit": "
|
|
40
|
+
"@adeildo/pi-kit": "5.0.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@earendil-works/pi-ai": "*",
|
|
44
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*"
|
|
45
46
|
},
|
|
46
47
|
"pi": {
|
|
47
48
|
"extensions": [
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The one place that answers which account a request uses.
|
|
2
|
+
import type { Pins } from "./pins.ts";
|
|
3
|
+
import type { AccountStore } from "./store.ts";
|
|
4
|
+
import type { Account } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The account a request uses: the session pin, else the store's choice, else none, which means pi's
|
|
8
|
+
* own credential. A request never falls back to "the first account", so adding one does not take
|
|
9
|
+
* over a session by itself.
|
|
10
|
+
*/
|
|
11
|
+
export function activeAccount(
|
|
12
|
+
store: AccountStore,
|
|
13
|
+
pins: Pins,
|
|
14
|
+
providerId: string,
|
|
15
|
+
): Account | undefined {
|
|
16
|
+
const pinned = pins.has(providerId) ? pins.get(providerId) : store.active(providerId)?.id;
|
|
17
|
+
if (pinned === null || pinned === undefined) return undefined;
|
|
18
|
+
return store.accounts(providerId).find((account) => account.id === pinned);
|
|
19
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
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";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
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";
|
|
15
|
+
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
|
+
|
|
41
|
+
export const accounts = defineFeature({
|
|
42
|
+
id: "accounts",
|
|
43
|
+
description: "Use more than one credential of the same provider",
|
|
44
|
+
tab: "Providers",
|
|
45
|
+
settings: [onLimit],
|
|
46
|
+
setup(scope) {
|
|
47
|
+
const store = new AccountStore();
|
|
48
|
+
const pins: Pins = new Map();
|
|
49
|
+
|
|
50
|
+
scope.screen.rows((ctx) => accountRows(scope, store, pins, ctx));
|
|
51
|
+
scope.on("model_select", (_event, ctx) => refreshStatus(store, pins, ctx));
|
|
52
|
+
scope.on("session_shutdown", (_event, ctx) => ctx.ui.setStatus(STATUS_KEY, undefined));
|
|
53
|
+
|
|
54
|
+
scope.onSessionStart((ctx) => {
|
|
55
|
+
for (const warning of store.reload()) scope.warn(warning);
|
|
56
|
+
pins.clear();
|
|
57
|
+
for (const [provider, account] of replay(ctx.sessionManager.getBranch())) {
|
|
58
|
+
pins.set(provider, account);
|
|
59
|
+
}
|
|
60
|
+
for (const providerId of new Set([...store.providerIds(), ...pins.keys()])) {
|
|
61
|
+
lift(scope, store, pins, ctx, providerId);
|
|
62
|
+
}
|
|
63
|
+
refreshStatus(store, pins, ctx);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
scope.registerShortcut(Key.alt("a"), {
|
|
67
|
+
description: `${NAME}: pick the account of the current provider`,
|
|
68
|
+
handler: (ctx) => pickAccount(scope, store, pins, ctx),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
scope.registerCommand("accounts", {
|
|
72
|
+
description: `${NAME}: add an account for a provider`,
|
|
73
|
+
handler: (args, ctx) => addAccount(scope, store, pins, args, ctx),
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function sessionFor(
|
|
79
|
+
scope: FeatureScope,
|
|
80
|
+
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];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function lift(
|
|
134
|
+
scope: FeatureScope,
|
|
135
|
+
store: AccountStore,
|
|
136
|
+
pins: Pins,
|
|
137
|
+
ctx: ExtensionContext,
|
|
138
|
+
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);
|
|
279
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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.
|
|
3
|
+
import type { AuthEvent, ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { LOGIN_KEY } from "./names.ts";
|
|
7
|
+
|
|
8
|
+
export function interactionFor(
|
|
9
|
+
ctx: ExtensionContext,
|
|
10
|
+
label: string,
|
|
11
|
+
signal: AbortSignal,
|
|
12
|
+
): ProviderAuthInteraction {
|
|
13
|
+
return {
|
|
14
|
+
signal,
|
|
15
|
+
async prompt(prompt) {
|
|
16
|
+
if (prompt.type === "select") {
|
|
17
|
+
const options = prompt.options.map((option) => option.label);
|
|
18
|
+
const picked = await ctx.ui.select(prompt.message, options, { signal: prompt.signal });
|
|
19
|
+
if (picked === undefined) throw new Error(`${label}: login cancelled`);
|
|
20
|
+
return prompt.options.find((option) => option.label === picked)?.id ?? picked;
|
|
21
|
+
}
|
|
22
|
+
const value = await ctx.ui.input(prompt.message, prompt.placeholder, {
|
|
23
|
+
signal: prompt.signal,
|
|
24
|
+
});
|
|
25
|
+
if (value === undefined) throw new Error(`${label}: login cancelled`);
|
|
26
|
+
return value;
|
|
27
|
+
},
|
|
28
|
+
notify(event) {
|
|
29
|
+
show(ctx, event);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function show(ctx: ExtensionContext, event: AuthEvent): void {
|
|
35
|
+
switch (event.type) {
|
|
36
|
+
case "info":
|
|
37
|
+
ctx.ui.notify(withLinks(event.message, event.links), "info");
|
|
38
|
+
break;
|
|
39
|
+
case "auth_url":
|
|
40
|
+
ctx.ui.notify(
|
|
41
|
+
withLinks(event.instructions ?? "Open this URL to sign in", [{ url: event.url }]),
|
|
42
|
+
"info",
|
|
43
|
+
);
|
|
44
|
+
break;
|
|
45
|
+
case "device_code":
|
|
46
|
+
ctx.ui.notify(`Enter ${event.userCode} at ${event.verificationUri}`, "info");
|
|
47
|
+
break;
|
|
48
|
+
case "progress":
|
|
49
|
+
ctx.ui.setStatus(LOGIN_KEY, event.message);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function withLinks(
|
|
55
|
+
message: string,
|
|
56
|
+
links: readonly { url: string; label?: string }[] | undefined,
|
|
57
|
+
): string {
|
|
58
|
+
if (links === undefined || links.length === 0) return message;
|
|
59
|
+
return `${message} ${links.map((link) => link.label ?? link.url).join(" ")}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// A provider with accounts is re-registered with its stream wrapped: each request resolves the
|
|
2
|
+
// credential of the active account and injects it, so one provider keeps one model list. The auth
|
|
3
|
+
// methods of the native provider do the work, so this file never speaks OAuth.
|
|
4
|
+
import {
|
|
5
|
+
defaultProviderAuthContext,
|
|
6
|
+
lazyStream,
|
|
7
|
+
type Api,
|
|
8
|
+
type ApiStreamOptions,
|
|
9
|
+
type AssistantMessage,
|
|
10
|
+
type AssistantMessageEvent,
|
|
11
|
+
type AssistantMessageEventStream,
|
|
12
|
+
type Credential,
|
|
13
|
+
type Model,
|
|
14
|
+
type ModelAuth,
|
|
15
|
+
type OAuthAuth,
|
|
16
|
+
type OAuthCredential,
|
|
17
|
+
type Provider,
|
|
18
|
+
type ProviderAuth,
|
|
19
|
+
type ProviderEnv,
|
|
20
|
+
type ProviderHeaders,
|
|
21
|
+
type SimpleStreamOptions,
|
|
22
|
+
type StreamOptions,
|
|
23
|
+
type TranscriptContext,
|
|
24
|
+
} from "@earendil-works/pi-ai";
|
|
25
|
+
|
|
26
|
+
/** The account the next request uses, and where a refreshed token is kept. */
|
|
27
|
+
export interface AccountSession {
|
|
28
|
+
resolve(): { id: string; credential: Credential } | undefined;
|
|
29
|
+
save(id: string, credential: Credential): void;
|
|
30
|
+
/** The account to try after this one hit a limit, or undefined to surface the error. */
|
|
31
|
+
afterLimit(
|
|
32
|
+
currentId: string,
|
|
33
|
+
detail: string,
|
|
34
|
+
): Promise<{ id: string; credential: Credential } | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The native provider behind a lifted one, so lifting twice wraps the same object. */
|
|
38
|
+
const NATIVE = Symbol.for("pi-providers.accounts.native");
|
|
39
|
+
|
|
40
|
+
/** The provider a lifted one came from, or the provider itself. */
|
|
41
|
+
export function nativeOf(provider: Provider): Provider {
|
|
42
|
+
return (provider as { [NATIVE]?: Provider })[NATIVE] ?? provider;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function liftProvider(provider: Provider, session: AccountSession): Provider {
|
|
46
|
+
// Every method is delegated by hand, not by spread: the native may keep methods on a prototype,
|
|
47
|
+
// and a copied method would lose its `this`. One refresh runs per account at a time.
|
|
48
|
+
const refreshing = new Map<string, Promise<OAuthCredential>>();
|
|
49
|
+
const getAllModels = provider.getAllModels;
|
|
50
|
+
const refreshModels = provider.refreshModels;
|
|
51
|
+
const filterModels = provider.filterModels;
|
|
52
|
+
const filterAllModels = provider.filterAllModels;
|
|
53
|
+
const fetchDeferred = provider.fetchDeferred;
|
|
54
|
+
const cancelDeferred = provider.cancelDeferred;
|
|
55
|
+
const generateImages = provider.generateImages;
|
|
56
|
+
const classify = provider.classify;
|
|
57
|
+
|
|
58
|
+
const lifted: Provider = {
|
|
59
|
+
id: provider.id,
|
|
60
|
+
name: provider.name,
|
|
61
|
+
...(provider.baseUrl === undefined ? {} : { baseUrl: provider.baseUrl }),
|
|
62
|
+
...(provider.headers === undefined ? {} : { headers: provider.headers }),
|
|
63
|
+
auth: liftedAuth(provider, session),
|
|
64
|
+
getModels: () => provider.getModels(),
|
|
65
|
+
...(getAllModels === undefined ? {} : { getAllModels: () => getAllModels() }),
|
|
66
|
+
...(refreshModels === undefined ? {} : { refreshModels: (context) => refreshModels(context) }),
|
|
67
|
+
...(filterModels === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { filterModels: (models, credential) => filterModels(models, credential) }),
|
|
70
|
+
...(filterAllModels === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { filterAllModels: (models, credential) => filterAllModels(models, credential) }),
|
|
73
|
+
stream<T extends Api>(
|
|
74
|
+
model: Model<T>,
|
|
75
|
+
context: TranscriptContext,
|
|
76
|
+
options?: ApiStreamOptions<T>,
|
|
77
|
+
) {
|
|
78
|
+
return accountStream(provider, session, refreshing, "stream", model, context, options);
|
|
79
|
+
},
|
|
80
|
+
streamSimple(model, context, options) {
|
|
81
|
+
return accountStream(provider, session, refreshing, "streamSimple", model, context, options);
|
|
82
|
+
},
|
|
83
|
+
...(fetchDeferred === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { fetchDeferred: (model, handle, options) => fetchDeferred(model, handle, options) }),
|
|
86
|
+
...(cancelDeferred === undefined
|
|
87
|
+
? {}
|
|
88
|
+
: { cancelDeferred: (model, handle, options) => cancelDeferred(model, handle, options) }),
|
|
89
|
+
...(generateImages === undefined
|
|
90
|
+
? {}
|
|
91
|
+
: { generateImages: (model, context, options) => generateImages(model, context, options) }),
|
|
92
|
+
...(classify === undefined
|
|
93
|
+
? {}
|
|
94
|
+
: { classify: (model, context, options) => classify(model, context, options) }),
|
|
95
|
+
};
|
|
96
|
+
Object.defineProperty(lifted, NATIVE, { value: provider, enumerable: false });
|
|
97
|
+
return lifted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type Options = StreamOptions;
|
|
101
|
+
|
|
102
|
+
function accountStream(
|
|
103
|
+
provider: Provider,
|
|
104
|
+
session: AccountSession,
|
|
105
|
+
refreshing: Map<string, Promise<OAuthCredential>>,
|
|
106
|
+
kind: "stream" | "streamSimple",
|
|
107
|
+
model: Model<Api>,
|
|
108
|
+
context: TranscriptContext,
|
|
109
|
+
options?: Options,
|
|
110
|
+
): AssistantMessageEventStream {
|
|
111
|
+
const first = session.resolve();
|
|
112
|
+
if (first === undefined) return call(provider, kind, model, context, options ?? {});
|
|
113
|
+
|
|
114
|
+
const settled = options ?? {};
|
|
115
|
+
return lazyStream(model, async () =>
|
|
116
|
+
attempts(provider, session, refreshing, kind, model, context, settled, first),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One account may serve a limit that another one does not have. */
|
|
121
|
+
const MAX_ATTEMPTS = 3;
|
|
122
|
+
|
|
123
|
+
// A quota or a rate limit belongs to the account, so another one can serve. A busy server cannot.
|
|
124
|
+
const LIMIT_PATTERN = /quota|rate[ _-]?limit|too many requests|usage limit|429/i;
|
|
125
|
+
|
|
126
|
+
async function* attempts(
|
|
127
|
+
provider: Provider,
|
|
128
|
+
session: AccountSession,
|
|
129
|
+
refreshing: Map<string, Promise<OAuthCredential>>,
|
|
130
|
+
kind: "stream" | "streamSimple",
|
|
131
|
+
model: Model<Api>,
|
|
132
|
+
context: TranscriptContext,
|
|
133
|
+
options: Options,
|
|
134
|
+
first: { id: string; credential: Credential },
|
|
135
|
+
): AsyncGenerator<AssistantMessageEvent> {
|
|
136
|
+
let account = first;
|
|
137
|
+
|
|
138
|
+
for (let attempt = 1; ; attempt++) {
|
|
139
|
+
// oxlint-disable-next-line no-await-in-loop -- an attempt starts only after the one before it.
|
|
140
|
+
const resolved = await resolveAuth(provider, session, refreshing, account, options.signal);
|
|
141
|
+
const withAuth = applyAuth(resolved, model, options);
|
|
142
|
+
const source = call(provider, kind, withAuth.model, context, withAuth.options);
|
|
143
|
+
|
|
144
|
+
// Hold the events until something is visible: a retry on another account must not repeat a
|
|
145
|
+
// message the caller already saw.
|
|
146
|
+
const buffered: AssistantMessageEvent[] = [];
|
|
147
|
+
let output = false;
|
|
148
|
+
let failure: AssistantMessage | undefined;
|
|
149
|
+
let thrown: unknown;
|
|
150
|
+
try {
|
|
151
|
+
// oxlint-disable-next-line no-await-in-loop -- a stream is read one event at a time.
|
|
152
|
+
for await (const event of source) {
|
|
153
|
+
if (isContent(event)) {
|
|
154
|
+
if (!output) {
|
|
155
|
+
output = true;
|
|
156
|
+
yield* buffered;
|
|
157
|
+
buffered.length = 0;
|
|
158
|
+
}
|
|
159
|
+
yield event;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (event.type === "error") failure = event.error;
|
|
163
|
+
if (output) yield event;
|
|
164
|
+
else buffered.push(event);
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
thrown = error;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!output && attempt < MAX_ATTEMPTS && failure !== undefined) {
|
|
171
|
+
const text = failure.errorMessage;
|
|
172
|
+
if (text !== undefined && LIMIT_PATTERN.test(text)) {
|
|
173
|
+
// oxlint-disable-next-line no-await-in-loop -- the answer comes from the user.
|
|
174
|
+
const next = await session.afterLimit(account.id, text);
|
|
175
|
+
if (next !== undefined) {
|
|
176
|
+
account = next;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
yield* buffered;
|
|
183
|
+
if (thrown !== undefined) throw thrown;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isContent(event: AssistantMessageEvent): boolean {
|
|
189
|
+
return event.type !== "start" && event.type !== "done" && event.type !== "error";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function call(
|
|
193
|
+
provider: Provider,
|
|
194
|
+
kind: "stream" | "streamSimple",
|
|
195
|
+
model: Model<Api>,
|
|
196
|
+
context: TranscriptContext,
|
|
197
|
+
options: Options,
|
|
198
|
+
): AssistantMessageEventStream {
|
|
199
|
+
if (kind === "streamSimple")
|
|
200
|
+
return provider.streamSimple(model, context, options as SimpleStreamOptions);
|
|
201
|
+
return provider.stream(model, context, options as ApiStreamOptions<Api>);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface ResolvedAuth {
|
|
205
|
+
auth: ModelAuth;
|
|
206
|
+
env?: ProviderEnv;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function resolveAuth(
|
|
210
|
+
provider: Provider,
|
|
211
|
+
session: AccountSession,
|
|
212
|
+
refreshing: Map<string, Promise<OAuthCredential>>,
|
|
213
|
+
account: { id: string; credential: Credential },
|
|
214
|
+
signal: AbortSignal | undefined,
|
|
215
|
+
): Promise<ResolvedAuth> {
|
|
216
|
+
const abort = signal ?? new AbortController().signal;
|
|
217
|
+
if (account.credential.type === "oauth") {
|
|
218
|
+
const oauth = provider.auth.oauth;
|
|
219
|
+
if (oauth === undefined) return { auth: {} };
|
|
220
|
+
const fresh = await refreshToken(
|
|
221
|
+
oauth,
|
|
222
|
+
session,
|
|
223
|
+
refreshing,
|
|
224
|
+
account.id,
|
|
225
|
+
account.credential,
|
|
226
|
+
abort,
|
|
227
|
+
);
|
|
228
|
+
return { auth: await oauth.toAuth(fresh) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const apiKey = provider.auth.apiKey;
|
|
232
|
+
if (apiKey === undefined) return { auth: {} };
|
|
233
|
+
const resolved = await apiKey.resolve({
|
|
234
|
+
ctx: defaultProviderAuthContext(),
|
|
235
|
+
credential: account.credential,
|
|
236
|
+
signal: abort,
|
|
237
|
+
});
|
|
238
|
+
return resolved === undefined ? { auth: {} } : { auth: resolved.auth, env: resolved.env };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Pi refreshes its own token under the store lock, five minutes early. Ours is ours to refresh, and
|
|
242
|
+
// one refresh per account is shared, so two requests cannot rotate the token twice.
|
|
243
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
244
|
+
|
|
245
|
+
async function refreshToken(
|
|
246
|
+
oauth: OAuthAuth,
|
|
247
|
+
session: AccountSession,
|
|
248
|
+
refreshing: Map<string, Promise<OAuthCredential>>,
|
|
249
|
+
accountId: string,
|
|
250
|
+
credential: OAuthCredential,
|
|
251
|
+
signal: AbortSignal,
|
|
252
|
+
): Promise<OAuthCredential> {
|
|
253
|
+
if (credential.expires > Date.now() + REFRESH_MARGIN_MS) return credential;
|
|
254
|
+
|
|
255
|
+
const inFlight = refreshing.get(accountId);
|
|
256
|
+
if (inFlight !== undefined) return await inFlight;
|
|
257
|
+
|
|
258
|
+
const task = oauth.refresh(credential, signal).then((fresh) => {
|
|
259
|
+
session.save(accountId, fresh);
|
|
260
|
+
return fresh;
|
|
261
|
+
});
|
|
262
|
+
refreshing.set(accountId, task);
|
|
263
|
+
try {
|
|
264
|
+
return await task;
|
|
265
|
+
} finally {
|
|
266
|
+
refreshing.delete(accountId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyAuth(
|
|
271
|
+
resolved: ResolvedAuth,
|
|
272
|
+
model: Model<Api>,
|
|
273
|
+
options: Options,
|
|
274
|
+
): { model: Model<Api>; options: Options } {
|
|
275
|
+
const next: Options = { ...options };
|
|
276
|
+
if (resolved.auth.apiKey === undefined) delete next.apiKey;
|
|
277
|
+
else next.apiKey = resolved.auth.apiKey;
|
|
278
|
+
|
|
279
|
+
const headers = mergeHeaders(options.headers, resolved.auth.headers);
|
|
280
|
+
if (headers === undefined) delete next.headers;
|
|
281
|
+
else next.headers = headers;
|
|
282
|
+
|
|
283
|
+
if (resolved.env === undefined && options.env === undefined) delete next.env;
|
|
284
|
+
else next.env = { ...options.env, ...resolved.env };
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
model:
|
|
288
|
+
resolved.auth.baseUrl === undefined ? model : { ...model, baseUrl: resolved.auth.baseUrl },
|
|
289
|
+
options: next,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function mergeHeaders(
|
|
294
|
+
base: ProviderHeaders | undefined,
|
|
295
|
+
override: ProviderHeaders | undefined,
|
|
296
|
+
): ProviderHeaders | undefined {
|
|
297
|
+
if (base === undefined && override === undefined) return undefined;
|
|
298
|
+
const merged: ProviderHeaders = { ...base };
|
|
299
|
+
for (const [name, value] of Object.entries(override ?? {})) {
|
|
300
|
+
const lower = name.toLowerCase();
|
|
301
|
+
for (const existing of Object.keys(merged)) {
|
|
302
|
+
if (existing.toLowerCase() === lower) delete merged[existing];
|
|
303
|
+
}
|
|
304
|
+
merged[name] = value;
|
|
305
|
+
}
|
|
306
|
+
return merged;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The runtime reads auth from the provider, so it has to report configured when an account exists,
|
|
310
|
+
// even though the credential itself is injected into the request by `accountStream`. Reporting the
|
|
311
|
+
// account's own type keeps `isUsingOAuth` honest, which is what bills a subscription request.
|
|
312
|
+
function liftedAuth(provider: Provider, session: AccountSession): ProviderAuth {
|
|
313
|
+
const original = provider.auth.apiKey;
|
|
314
|
+
const login = original?.login;
|
|
315
|
+
return {
|
|
316
|
+
apiKey: {
|
|
317
|
+
name: original?.name ?? `${provider.name} accounts`,
|
|
318
|
+
...(login === undefined ? {} : { login: (interaction) => login(interaction) }),
|
|
319
|
+
async check(input) {
|
|
320
|
+
const account = session.resolve();
|
|
321
|
+
if (account !== undefined) return { type: account.credential.type, source: "account" };
|
|
322
|
+
return original?.check?.(input);
|
|
323
|
+
},
|
|
324
|
+
async resolve(input) {
|
|
325
|
+
if (session.resolve() !== undefined) return { auth: {}, source: "account" };
|
|
326
|
+
return original?.resolve(input);
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
...(provider.auth.oauth === undefined ? {} : { oauth: provider.auth.oauth }),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthType,
|
|
3
|
+
Credential,
|
|
4
|
+
Provider,
|
|
5
|
+
ProviderAuthInteraction,
|
|
6
|
+
} from "@earendil-works/pi-ai";
|
|
7
|
+
|
|
8
|
+
/** One way a provider can log in, named the way its own login dialog names it. */
|
|
9
|
+
export interface LoginMethod {
|
|
10
|
+
provider: Provider;
|
|
11
|
+
authType: AuthType;
|
|
12
|
+
label: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every way this provider logs in, OAuth first. */
|
|
16
|
+
export function loginMethods(provider: Provider): LoginMethod[] {
|
|
17
|
+
const methods: LoginMethod[] = [];
|
|
18
|
+
const oauth = provider.auth.oauth;
|
|
19
|
+
if (oauth !== undefined) {
|
|
20
|
+
methods.push({ provider, authType: "oauth", label: oauth.loginLabel ?? oauth.name });
|
|
21
|
+
}
|
|
22
|
+
const apiKey = provider.auth.apiKey;
|
|
23
|
+
if (apiKey?.login !== undefined)
|
|
24
|
+
methods.push({ provider, authType: "api_key", label: apiKey.name });
|
|
25
|
+
return methods;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runs the provider's own login, so the OAuth flow and the key prompt both stay pi's. */
|
|
29
|
+
export async function login(
|
|
30
|
+
method: LoginMethod,
|
|
31
|
+
interaction: ProviderAuthInteraction,
|
|
32
|
+
): Promise<Credential> {
|
|
33
|
+
if (method.authType === "oauth") {
|
|
34
|
+
const oauth = method.provider.auth.oauth;
|
|
35
|
+
if (oauth === undefined) throw new Error(`${method.provider.id} has no OAuth login`);
|
|
36
|
+
return await oauth.login(interaction);
|
|
37
|
+
}
|
|
38
|
+
const apiKey = method.provider.auth.apiKey;
|
|
39
|
+
if (apiKey?.login === undefined) throw new Error(`${method.provider.id} has no key login`);
|
|
40
|
+
return await apiKey.login(interaction);
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Written into session files and into pi's status line, so these names stay stable.
|
|
2
|
+
export const NAME = "pi-providers";
|
|
3
|
+
|
|
4
|
+
/** The session entry that pins an account for the branch. */
|
|
5
|
+
export const SESSION_ENTRY = `${NAME}:accounts`;
|
|
6
|
+
|
|
7
|
+
/** What the look reads for the account the current model uses. */
|
|
8
|
+
export const STATUS_KEY = `${NAME}:account`;
|
|
9
|
+
|
|
10
|
+
/** The status while a provider login runs. */
|
|
11
|
+
export const LOGIN_KEY = `${NAME}:login`;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The account a session uses lives in the session file, so a resume or a fork comes back with the
|
|
2
|
+
// account it had. The store holds the default; a pin here only overrides it for one branch.
|
|
3
|
+
import { isObject } from "@adeildo/pi-kit";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { SESSION_ENTRY } from "./names.ts";
|
|
7
|
+
|
|
8
|
+
/** The account pinned for a provider. `null` means the credential pi itself has. */
|
|
9
|
+
export type Pins = Map<string, string | null>;
|
|
10
|
+
|
|
11
|
+
export type PinEntry =
|
|
12
|
+
| { kind: "account"; provider: string; account: string }
|
|
13
|
+
| { kind: "default"; provider: string };
|
|
14
|
+
|
|
15
|
+
export function record(pi: ExtensionAPI, entry: PinEntry): void {
|
|
16
|
+
pi.appendEntry(SESSION_ENTRY, entry);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pins the account for the rest of the session, and writes it into the session file. */
|
|
20
|
+
export function pin(
|
|
21
|
+
pi: ExtensionAPI,
|
|
22
|
+
pins: Pins,
|
|
23
|
+
providerId: string,
|
|
24
|
+
accountId: string | null,
|
|
25
|
+
): void {
|
|
26
|
+
pins.set(providerId, accountId);
|
|
27
|
+
record(
|
|
28
|
+
pi,
|
|
29
|
+
accountId === null
|
|
30
|
+
? { kind: "default", provider: providerId }
|
|
31
|
+
: { kind: "account", provider: providerId, account: accountId },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The pins a branch recorded, newest winning. */
|
|
36
|
+
export function replay(branch: readonly unknown[]): Pins {
|
|
37
|
+
const pins: Pins = new Map();
|
|
38
|
+
for (const item of branch) {
|
|
39
|
+
const entry = pinEntry(item);
|
|
40
|
+
if (entry === undefined) continue;
|
|
41
|
+
if (entry.kind === "default") pins.set(entry.provider, null);
|
|
42
|
+
else pins.set(entry.provider, entry.account);
|
|
43
|
+
}
|
|
44
|
+
return pins;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pinEntry(item: unknown): PinEntry | undefined {
|
|
48
|
+
if (!isObject(item) || item.type !== "custom" || item.customType !== SESSION_ENTRY)
|
|
49
|
+
return undefined;
|
|
50
|
+
const data = item.data;
|
|
51
|
+
if (!isObject(data)) return undefined;
|
|
52
|
+
const provider = typeof data.provider === "string" ? data.provider : undefined;
|
|
53
|
+
if (provider === undefined) return undefined;
|
|
54
|
+
if (data.kind === "default") return { kind: "default", provider };
|
|
55
|
+
if (data.kind === "account" && typeof data.account === "string")
|
|
56
|
+
return { kind: "account", provider, account: data.account };
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The Accounts rows of the settings screen, one tree per provider: the accounts, and under each one
|
|
2
|
+
// what can be done to it. Adding stays on `/accounts`, which needs the provider's login dialogs.
|
|
3
|
+
import type { FeatureScope, Json, ScreenEntry } from "@adeildo/pi-kit";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { activeAccount } from "./active.ts";
|
|
7
|
+
import { 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
|
+
/** 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
|
+
/** 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;
|
|
22
|
+
label: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function accountRows(
|
|
26
|
+
pi: FeatureScope,
|
|
27
|
+
store: AccountStore,
|
|
28
|
+
pins: Pins,
|
|
29
|
+
ctx: ExtensionContext,
|
|
30
|
+
): 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
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One account, and its actions, one level deeper. */
|
|
68
|
+
function accountBlock(group: ProviderGroup, account: Account): ScreenEntry[] {
|
|
69
|
+
const subscription = account.credential.type === "oauth";
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
kind: "action",
|
|
73
|
+
id: `accounts.${group.providerId}.${account.id}.use`,
|
|
74
|
+
section: group.label,
|
|
75
|
+
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
|
+
},
|
|
81
|
+
indent: 1,
|
|
82
|
+
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);
|
|
86
|
+
return `${account.label} in use`;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
kind: "value",
|
|
91
|
+
id: `accounts.${group.providerId}.${account.id}.label`,
|
|
92
|
+
section: group.label,
|
|
93
|
+
label: "Rename",
|
|
94
|
+
description: "What this account is called in the picker.",
|
|
95
|
+
control: { type: "text" },
|
|
96
|
+
indent: 2,
|
|
97
|
+
get: () => account.label,
|
|
98
|
+
set: (value: Json, ctx) => {
|
|
99
|
+
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;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
kind: "action",
|
|
107
|
+
id: `accounts.${group.providerId}.${account.id}.remove`,
|
|
108
|
+
section: group.label,
|
|
109
|
+
label: "Remove",
|
|
110
|
+
description: `Forget the "${account.label}" credential.`,
|
|
111
|
+
text: () => account.label,
|
|
112
|
+
confirm: `Remove the ${group.providerId} account "${account.label}"?`,
|
|
113
|
+
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
|
+
},
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
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));
|
|
147
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The extra accounts live beside the settings, one file for every provider. Pi's own credential is
|
|
2
|
+
// not copied here: it stays in pi's store, and is what a request uses when no account is chosen.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { globalSettingsPath, isObject, readSettingsFile, writeSettingsFile } from "@adeildo/pi-kit";
|
|
7
|
+
import type { Credential } from "@earendil-works/pi-ai";
|
|
8
|
+
|
|
9
|
+
import { reason } from "./describe.ts";
|
|
10
|
+
import type { Account, AccountsFile, ProviderAccounts } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export const ACCOUNTS_FILE = "accounts.json";
|
|
13
|
+
|
|
14
|
+
export function accountsPath(): string {
|
|
15
|
+
return join(dirname(globalSettingsPath()), ACCOUNTS_FILE);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What an add produced: the account it wrote, or why it did not. */
|
|
19
|
+
export interface Added {
|
|
20
|
+
account?: Account;
|
|
21
|
+
problem?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AccountStore {
|
|
25
|
+
private readonly path: string;
|
|
26
|
+
private data: AccountsFile | undefined;
|
|
27
|
+
|
|
28
|
+
constructor(path: string = accountsPath()) {
|
|
29
|
+
this.path = path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reads the file again, for another pi's write or a hand edit. */
|
|
33
|
+
reload(): string[] {
|
|
34
|
+
const read = readAccounts(this.path);
|
|
35
|
+
this.data = { version: 1, providers: read.providers };
|
|
36
|
+
return read.warnings;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The providers with at least one account. */
|
|
40
|
+
providerIds(): string[] {
|
|
41
|
+
return Object.keys(this.file().providers);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
accounts(providerId: string): Account[] {
|
|
45
|
+
return this.file().providers[providerId]?.accounts ?? [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
has(providerId: string): boolean {
|
|
49
|
+
return this.accounts(providerId).length > 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The account the store prefers. Without one the feature keeps pi's own credential. */
|
|
53
|
+
active(providerId: string): Account | undefined {
|
|
54
|
+
const entry = this.file().providers[providerId];
|
|
55
|
+
if (entry?.active === undefined) return undefined;
|
|
56
|
+
return entry.accounts.find((account) => account.id === entry.active);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
add(providerId: string, label: string, credential: Credential): Added {
|
|
60
|
+
const account: Account = { id: randomUUID(), label: label.trim() || providerId, credential };
|
|
61
|
+
const problem = this.edit((providers) => {
|
|
62
|
+
const entry = providers[providerId] ?? { accounts: [] };
|
|
63
|
+
entry.accounts.push(account);
|
|
64
|
+
providers[providerId] = entry;
|
|
65
|
+
});
|
|
66
|
+
return problem === undefined ? { account } : { problem };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
remove(providerId: string, id: string): string | undefined {
|
|
70
|
+
return this.edit((providers) => {
|
|
71
|
+
const entry = providers[providerId];
|
|
72
|
+
if (entry === undefined) return;
|
|
73
|
+
entry.accounts = entry.accounts.filter((account) => account.id !== id);
|
|
74
|
+
if (entry.active === id) delete entry.active;
|
|
75
|
+
if (entry.accounts.length === 0) delete providers[providerId];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
rename(providerId: string, id: string, label: string): string | undefined {
|
|
80
|
+
return this.edit((providers) => {
|
|
81
|
+
const account = providers[providerId]?.accounts.find((entry) => entry.id === id);
|
|
82
|
+
if (account !== undefined) account.label = label.trim() || account.label;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Sets the account the store prefers, or clears the choice with undefined. */
|
|
87
|
+
setActive(providerId: string, id: string | undefined): string | undefined {
|
|
88
|
+
return this.edit((providers) => {
|
|
89
|
+
const entry = providers[providerId];
|
|
90
|
+
if (entry === undefined) return;
|
|
91
|
+
if (id === undefined) delete entry.active;
|
|
92
|
+
else if (entry.accounts.some((account) => account.id === id)) entry.active = id;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Keeps the credential a refresh produced. */
|
|
97
|
+
setCredential(providerId: string, id: string, credential: Credential): string | undefined {
|
|
98
|
+
return this.edit((providers) => {
|
|
99
|
+
const account = providers[providerId]?.accounts.find((entry) => entry.id === id);
|
|
100
|
+
if (account !== undefined) account.credential = credential;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private file(): AccountsFile {
|
|
105
|
+
if (this.data === undefined) this.reload();
|
|
106
|
+
return this.data ?? { version: 1, providers: {} };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Reads, changes and writes, so another pi's write since the last read is not lost. */
|
|
110
|
+
private edit(change: (providers: Record<string, ProviderAccounts>) => void): string | undefined {
|
|
111
|
+
const read = readAccounts(this.path);
|
|
112
|
+
const warning = read.warnings[0];
|
|
113
|
+
if (warning !== undefined) return warning;
|
|
114
|
+
change(read.providers);
|
|
115
|
+
this.data = { version: 1, providers: read.providers };
|
|
116
|
+
return writeAccounts(this.path, this.data);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readAccounts(path: string): {
|
|
121
|
+
providers: Record<string, ProviderAccounts>;
|
|
122
|
+
warnings: string[];
|
|
123
|
+
} {
|
|
124
|
+
const file = readSettingsFile(path);
|
|
125
|
+
if (file.warnings.length > 0) return { providers: {}, warnings: file.warnings };
|
|
126
|
+
|
|
127
|
+
const source = file.data.providers;
|
|
128
|
+
if (!isObject(source)) return { providers: {}, warnings: [] };
|
|
129
|
+
|
|
130
|
+
const providers: Record<string, ProviderAccounts> = {};
|
|
131
|
+
for (const [providerId, value] of Object.entries(source)) {
|
|
132
|
+
if (!isObject(value) || !Array.isArray(value.accounts)) continue;
|
|
133
|
+
const accounts = value.accounts.filter(isAccount);
|
|
134
|
+
if (accounts.length === 0) continue;
|
|
135
|
+
providers[providerId] = {
|
|
136
|
+
accounts,
|
|
137
|
+
...(typeof value.active === "string" ? { active: value.active } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { providers, warnings: [] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeAccounts(path: string, data: AccountsFile): string | undefined {
|
|
144
|
+
try {
|
|
145
|
+
writeSettingsFile(path, data, { mode: 0o600 });
|
|
146
|
+
return undefined;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return reason(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isCredential(input: unknown): input is Credential {
|
|
162
|
+
if (!isObject(input)) return false;
|
|
163
|
+
if (input.type === "oauth")
|
|
164
|
+
return typeof input.access === "string" && typeof input.refresh === "string";
|
|
165
|
+
if (input.type === "api_key") return input.key === undefined || typeof input.key === "string";
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Credential } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
/** One extra credential with a name. Pi's own credential stays in pi's store, not here. */
|
|
4
|
+
export interface Account {
|
|
5
|
+
/** Stable id, so renaming an account keeps a session pinned to it. */
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
credential: Credential;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The extra accounts of one provider, and the one the store prefers. */
|
|
12
|
+
export interface ProviderAccounts {
|
|
13
|
+
/** The id of the account the store prefers. Absent means pi's own credential. */
|
|
14
|
+
active?: string;
|
|
15
|
+
accounts: Account[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AccountsFile = {
|
|
19
|
+
version: 1;
|
|
20
|
+
providers: Record<string, ProviderAccounts>;
|
|
21
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createApp } from "@adeildo/pi-kit";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
|
+
import { accounts } from "./accounts/feature.ts";
|
|
5
|
+
import { NAME } from "./accounts/names.ts";
|
|
4
6
|
import { subscription } from "./subscription/feature.ts";
|
|
5
7
|
|
|
8
|
+
export { accounts, onLimit, WHEN_LIMITED, type WhenLimited } from "./accounts/feature.ts";
|
|
9
|
+
export { accountsPath, AccountStore } from "./accounts/store.ts";
|
|
6
10
|
export { claudeCodeVersion, subscription } from "./subscription/feature.ts";
|
|
7
11
|
|
|
8
12
|
export default function piProviders(pi: ExtensionAPI): void {
|
|
9
|
-
createApp(pi, { name:
|
|
13
|
+
createApp(pi, { name: NAME }).use(subscription).use(accounts).build();
|
|
10
14
|
}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// How Claude Code tells Anthropic who it is. A request is billed to the Claude plan when it
|
|
2
|
-
// carries Claude Code's user agent and a first system block with a billing attribution, which ends
|
|
3
|
-
// in three characters of the first user message hashed with the version and a salt.
|
|
4
1
|
import { createHash } from "node:crypto";
|
|
5
2
|
|
|
6
3
|
export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Bills Anthropic OAuth requests to the Claude Pro/Max plan instead of extra usage. It acts on any
|
|
2
|
-
// provider that speaks the Anthropic Messages API with an OAuth token, not only the one named
|
|
3
|
-
// `anthropic`, so a second account under another name gets the same treatment.
|
|
4
1
|
import { defineFeature, isObject, matching, setting } from "@adeildo/pi-kit";
|
|
5
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
3
|
|
|
@@ -12,7 +9,7 @@ export const claudeCodeVersion = setting({
|
|
|
12
9
|
default: "2.1.280",
|
|
13
10
|
decoder: matching(/^\d+\.\d+\.\d+$/, "a Claude Code version like 2.1.280"),
|
|
14
11
|
ui: {
|
|
15
|
-
|
|
12
|
+
section: "Anthropic subscription",
|
|
16
13
|
label: "Claude Code version",
|
|
17
14
|
description:
|
|
18
15
|
"The Claude Code version pi reports to Anthropic. Anthropic refuses newer models to versions it considers too old.",
|
|
@@ -22,6 +19,7 @@ export const claudeCodeVersion = setting({
|
|
|
22
19
|
export const subscription = defineFeature({
|
|
23
20
|
id: "subscription",
|
|
24
21
|
description: "Bill Anthropic OAuth requests to the Claude plan instead of extra usage",
|
|
22
|
+
tab: "Providers",
|
|
25
23
|
settings: [claudeCodeVersion],
|
|
26
24
|
setup(scope) {
|
|
27
25
|
scope.on("before_provider_headers", (event, ctx) => {
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Rewrites the request pi builds for an Anthropic OAuth token. Anthropic's billing classifier reads
|
|
2
|
-
// `system`, and any text there that Claude Code would not send costs extra usage. So `system` keeps
|
|
3
|
-
// the attribution and the identity, and pi's prompt moves into the first user message, where the
|
|
4
|
-
// model still follows it.
|
|
5
1
|
import { isObject } from "@adeildo/pi-kit";
|
|
6
2
|
|
|
7
3
|
import { billingAttribution, CLAUDE_CODE_IDENTITY } from "./billing.ts";
|
|
@@ -62,8 +58,7 @@ function prependInstructions(messages: readonly unknown[], moved: readonly TextB
|
|
|
62
58
|
.join("\n\n");
|
|
63
59
|
if (text === "") return [...messages];
|
|
64
60
|
|
|
65
|
-
// The moved prompt takes over
|
|
66
|
-
// number of breakpoints and the prompt stays cached on its own.
|
|
61
|
+
// The moved prompt takes over pi's cache breakpoint, so the breakpoint count stays the same.
|
|
67
62
|
const cacheControl = moved.findLast((block) => block.cache_control !== undefined)?.cache_control;
|
|
68
63
|
const instructions: TextBlock = {
|
|
69
64
|
type: "text",
|