@hieplp/pi-account-switcher 0.2.1 → 0.2.3
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 +32 -22
- package/package.json +12 -8
- package/src/commands/accounts/add.ts +1 -1
- package/src/commands/accounts/edit.ts +2 -2
- package/src/commands/accounts/index.ts +3 -1
- package/src/commands/accounts/list.ts +1 -1
- package/src/commands/accounts/oauth.ts +1 -1
- package/src/commands/accounts/remove.ts +1 -1
- package/src/commands/accounts/shared/base.ts +34 -3
- package/src/commands/accounts/shared/prompts.ts +36 -12
- package/src/commands/accounts/switch.ts +6 -3
- package/src/commands/accounts/verify.ts +298 -0
- package/src/commands/base.ts +11 -5
- package/src/commands/index.ts +1 -1
- package/src/commands/models/add.ts +1 -1
- package/src/commands/models/index.ts +1 -1
- package/src/commands/models/list.ts +16 -6
- package/src/commands/models/remove.ts +2 -2
- package/src/commands/models/shared/base.ts +7 -3
- package/src/commands/models/shared/prompts.ts +1 -1
- package/src/commands/models/shared/select.ts +1 -1
- package/src/commands/providers/add.ts +1 -1
- package/src/commands/providers/edit.ts +1 -1
- package/src/commands/providers/index.ts +1 -1
- package/src/commands/providers/list.ts +5 -2
- package/src/commands/providers/remove.ts +1 -1
- package/src/commands/providers/shared/base.ts +1 -1
- package/src/commands/providers/shared/prompts.ts +24 -25
- package/src/commands/providers/shared/select.ts +1 -1
- package/src/commands/system/export.ts +1 -2
- package/src/commands/system/import.ts +9 -3
- package/src/commands/system/index.ts +1 -1
- package/src/commands/system/reset.ts +1 -1
- package/src/constants/commands.ts +5 -0
- package/src/constants/env.ts +1 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/paths.ts +1 -1
- package/src/extension.ts +2 -4
- package/src/index.ts +1 -1
- package/src/runtime/account-switcher-runtime.ts +23 -13
- package/src/runtime/account-switcher.ts +2 -1
- package/src/runtime/index.ts +2 -4
- package/src/schemas/accounts.ts +1 -2
- package/src/schemas/providers.ts +2 -6
- package/src/services/models.ts +2 -2
- package/src/services/providers.ts +3 -9
- package/src/types/context.ts +1 -1
- package/src/types/index.ts +1 -1
- package/src/utils/accounts.ts +2 -2
- package/src/utils/commands.ts +10 -0
- package/src/utils/common.ts +15 -0
- package/src/utils/errors.ts +1 -3
- package/src/utils/filterable-selector.ts +123 -3
- package/src/utils/index.ts +1 -0
- package/src/utils/models.ts +4 -2
- package/src/utils/ui.ts +36 -2
package/README.md
CHANGED
|
@@ -48,45 +48,55 @@ After installing, reload Pi and add your first account:
|
|
|
48
48
|
/accounts:add
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
### Local development command prefix
|
|
52
|
+
|
|
53
|
+
If you have the npm package installed and also run a local checkout, set `PI_ACCOUNT_SWITCHER_COMMAND_PREFIX` before launching Pi to avoid command-name collisions:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
PI_ACCOUNT_SWITCHER_COMMAND_PREFIX=dev pi -e ./src/extension.ts
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The local commands will be registered as `/dev:accounts:list`, `/dev:accounts:add`, etc. The prefix may include the trailing colon (`dev:`) or omit it (`dev`).
|
|
60
|
+
|
|
51
61
|
---
|
|
52
62
|
|
|
53
63
|
## Commands
|
|
54
64
|
|
|
55
65
|
### Accounts
|
|
56
66
|
|
|
57
|
-
| Command
|
|
58
|
-
|
|
59
|
-
| `/accounts:add`
|
|
60
|
-
| `/accounts:list`
|
|
61
|
-
| `/accounts:switch` | Switch to another account within the current provider
|
|
62
|
-
| `/accounts:edit`
|
|
63
|
-
| `/accounts:remove` | Delete an account
|
|
64
|
-
| `/accounts:oauth`
|
|
67
|
+
| Command | Description |
|
|
68
|
+
| ------------------ | --------------------------------------------------------------- |
|
|
69
|
+
| `/accounts:add` | Add a new account interactively |
|
|
70
|
+
| `/accounts:list` | List all accounts and activate the selected one |
|
|
71
|
+
| `/accounts:switch` | Switch to another account within the current provider |
|
|
72
|
+
| `/accounts:edit` | Edit label, provider, id, or credential source |
|
|
73
|
+
| `/accounts:remove` | Delete an account |
|
|
74
|
+
| `/accounts:oauth` | Import the current Pi `/login` OAuth session as a named account |
|
|
65
75
|
|
|
66
76
|
### Providers
|
|
67
77
|
|
|
68
|
-
| Command
|
|
69
|
-
|
|
70
|
-
| `/providers:add`
|
|
71
|
-
| `/providers:list`
|
|
72
|
-
| `/providers:edit`
|
|
78
|
+
| Command | Description |
|
|
79
|
+
| ------------------- | -------------------------------- |
|
|
80
|
+
| `/providers:add` | Add a reusable custom provider |
|
|
81
|
+
| `/providers:list` | List custom providers |
|
|
82
|
+
| `/providers:edit` | Edit a custom provider |
|
|
73
83
|
| `/providers:remove` | Remove an unused custom provider |
|
|
74
84
|
|
|
75
85
|
### Models
|
|
76
86
|
|
|
77
|
-
| Command
|
|
78
|
-
|
|
79
|
-
| `/models:list`
|
|
80
|
-
| `/models:add`
|
|
81
|
-
| `/models:remove` | Remove a custom model config
|
|
87
|
+
| Command | Description |
|
|
88
|
+
| ---------------- | -------------------------------------------------------- |
|
|
89
|
+
| `/models:list` | List all available models and switch to the selected one |
|
|
90
|
+
| `/models:add` | Add a custom model config to the current provider |
|
|
91
|
+
| `/models:remove` | Remove a custom model config |
|
|
82
92
|
|
|
83
93
|
### System
|
|
84
94
|
|
|
85
|
-
| Command
|
|
86
|
-
|
|
87
|
-
| `/system:reset`
|
|
95
|
+
| Command | Description |
|
|
96
|
+
| ---------------- | -------------------------------------------------------- |
|
|
97
|
+
| `/system:reset` | Delete all accounts, providers, and state |
|
|
88
98
|
| `/system:export` | Export all accounts, providers, and state to a JSON file |
|
|
89
|
-
| `/system:import` | Import accounts, providers, and state from a JSON file
|
|
99
|
+
| `/system:import` | Import accounts, providers, and state from a JSON file |
|
|
90
100
|
|
|
91
101
|
---
|
|
92
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hieplp/pi-account-switcher",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension for quickly switching between multiple accounts/API keys per provider.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,21 +27,24 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
|
-
"test": "vitest run"
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"format": "prettier --write .",
|
|
32
|
+
"format:check": "prettier --check ."
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
35
|
+
"@earendil-works/pi-tui": "^0.74.0",
|
|
33
36
|
"@mariozechner/jiti": "^2.6.5",
|
|
34
|
-
"@mariozechner/pi-tui": "^0.73.1",
|
|
35
37
|
"zod": "^4.4.3"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
|
38
|
-
"@
|
|
39
|
-
"@
|
|
40
|
+
"@earendil-works/pi-ai": "*",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
|
-
"@
|
|
43
|
-
"@
|
|
44
|
+
"@earendil-works/pi-ai": "latest",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "latest",
|
|
44
46
|
"@types/node": "latest",
|
|
47
|
+
"prettier": "^3.8.3",
|
|
45
48
|
"typescript": "latest",
|
|
46
49
|
"vitest": "latest"
|
|
47
50
|
},
|
|
@@ -49,5 +52,6 @@
|
|
|
49
52
|
"extensions": [
|
|
50
53
|
"./src/extension.ts"
|
|
51
54
|
]
|
|
52
|
-
}
|
|
55
|
+
},
|
|
56
|
+
"packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1"
|
|
53
57
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import type { AccountConfig, AccountSwitcherContext } from "@/types";
|
|
4
4
|
import { COMMANDS } from "@/constants";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import type { AccountSwitcherContext } from "@/types";
|
|
4
4
|
import { COMMANDS } from "@/constants";
|
|
@@ -19,7 +19,7 @@ class EditAccountCommand extends AccountCommand {
|
|
|
19
19
|
const original = await this.loadAndSelectAccount(ctx, "Select account to edit");
|
|
20
20
|
if (!original) return;
|
|
21
21
|
|
|
22
|
-
const updated = await new AccountConfigBuilder(ctx.ui, this.runtime.getProviders(), original).collect();
|
|
22
|
+
const updated = await new AccountConfigBuilder(ctx.ui, this.runtime.getProviders(), original).collect(true);
|
|
23
23
|
if (!updated) return;
|
|
24
24
|
|
|
25
25
|
await this.runtime.editAccount(original, updated);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import { useAddAccountCommand } from "./add";
|
|
4
4
|
import { useEditAccountCommand } from "./edit";
|
|
@@ -6,6 +6,7 @@ import { useListAccountsCommand } from "./list";
|
|
|
6
6
|
import { useOAuthImportCommand } from "./oauth";
|
|
7
7
|
import { useRemoveAccountCommand } from "./remove";
|
|
8
8
|
import { useSwitchAccountCommand } from "./switch";
|
|
9
|
+
import { useVerifyAccountsCommand } from "./verify";
|
|
9
10
|
|
|
10
11
|
const useAccountCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
|
|
11
12
|
useAddAccountCommand(pi, runtime);
|
|
@@ -14,6 +15,7 @@ const useAccountCommands = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
|
|
|
14
15
|
useOAuthImportCommand(pi, runtime);
|
|
15
16
|
useRemoveAccountCommand(pi, runtime);
|
|
16
17
|
useSwitchAccountCommand(pi, runtime);
|
|
18
|
+
useVerifyAccountsCommand(pi, runtime);
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export default useAccountCommands;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import type { AccountSwitcherContext } from "@/types";
|
|
4
4
|
import { COMMANDS } from "@/constants";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import type { AccountConfig, AccountSwitcherContext } from "@/types";
|
|
4
4
|
import { COMMANDS, OAUTH_PROVIDER_IDS, PI_AUTH_PATH } from "@/constants";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import type { AccountSwitcherContext } from "@/types";
|
|
4
4
|
import { COMMANDS } from "@/constants";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import type { AccountConfig, AccountSwitcherContext } from "@/types";
|
|
4
4
|
import { uiUtil } from "@/utils";
|
|
@@ -69,6 +69,31 @@ export abstract class AccountCommand extends BaseCommand {
|
|
|
69
69
|
accounts: AccountConfig[],
|
|
70
70
|
label = "Pick account",
|
|
71
71
|
): Promise<AccountConfig | undefined> {
|
|
72
|
+
const { labels, values } = this.buildGroupedAccountSelectItems(accounts, true);
|
|
73
|
+
return this.pickGrouped(ctx, label, labels, values);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
protected async pickGroupedAccounts(
|
|
77
|
+
ctx: AccountSwitcherContext,
|
|
78
|
+
accounts: AccountConfig[],
|
|
79
|
+
label = "Pick accounts",
|
|
80
|
+
): Promise<AccountConfig[] | undefined> {
|
|
81
|
+
const activeId = this.runtime.getActiveAccount()?.id;
|
|
82
|
+
const { labels, values } = this.buildGroupedAccountSelectItems(accounts, false);
|
|
83
|
+
const firstAccountIndex = values.findIndex((value) => value !== null);
|
|
84
|
+
const initialChecked = values.map((value, index) =>
|
|
85
|
+
activeId ? value?.id === activeId : firstAccountIndex !== -1 && index === firstAccountIndex,
|
|
86
|
+
);
|
|
87
|
+
return uiUtil.multiGroupedSelect(ctx.ui, label, labels, values, initialChecked);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private buildGroupedAccountSelectItems(
|
|
91
|
+
accounts: AccountConfig[],
|
|
92
|
+
includeActiveMarker: boolean,
|
|
93
|
+
): {
|
|
94
|
+
labels: string[];
|
|
95
|
+
values: Array<AccountConfig | null>;
|
|
96
|
+
} {
|
|
72
97
|
const items = buildGroupedItems(accounts, this.runtime.getProviders(), this.runtime.getActiveAccount()?.id);
|
|
73
98
|
|
|
74
99
|
const labels: string[] = [];
|
|
@@ -79,10 +104,16 @@ export abstract class AccountCommand extends BaseCommand {
|
|
|
79
104
|
values.push(null);
|
|
80
105
|
continue;
|
|
81
106
|
}
|
|
82
|
-
labels.push(formatAccountItem(item));
|
|
107
|
+
labels.push(includeActiveMarker ? formatAccountItem(item) : this.formatMultiAccountItem(item));
|
|
83
108
|
values.push(item.account);
|
|
84
109
|
}
|
|
85
110
|
|
|
86
|
-
return
|
|
111
|
+
return { labels, values };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private formatMultiAccountItem(
|
|
115
|
+
item: Extract<ReturnType<typeof buildGroupedItems>[number], { type: "account" }>,
|
|
116
|
+
): string {
|
|
117
|
+
return item.active ? `${item.account.label} (active)` : item.account.label;
|
|
87
118
|
}
|
|
88
119
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionUIContext } from "@
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountConfig, ProviderConfig, SecretSource } from "@/types";
|
|
3
3
|
import { commonUtil, providerUtil, uiUtil } from "@/utils";
|
|
4
4
|
import { ACCOUNTS_PATH } from "@/constants";
|
|
@@ -37,12 +37,14 @@ export class AccountConfigBuilder {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async withProvider(): Promise<this> {
|
|
40
|
-
const
|
|
40
|
+
const choices = providerUtil.providerChoices(this.customProviders);
|
|
41
|
+
const choice = await uiUtil.filteredSelect(this.ui, "Provider", choices);
|
|
41
42
|
if (!choice) return this;
|
|
42
43
|
|
|
43
44
|
const raw = choice === "custom" ? await this.prompt("Custom provider", "provider-id").asText() : choice;
|
|
44
45
|
const provider = providerUtil.normalizeProvider(raw ?? "");
|
|
45
|
-
|
|
46
|
+
// If provider is empty (user cancelled), return early. collect() will detect missing provider and return undefined.
|
|
47
|
+
if (!provider) return this;
|
|
46
48
|
|
|
47
49
|
this.config.provider = provider;
|
|
48
50
|
this.customProvider = providerUtil.findProvider(provider, this.customProviders);
|
|
@@ -101,21 +103,32 @@ export class AccountConfigBuilder {
|
|
|
101
103
|
const { provider } = this.config;
|
|
102
104
|
if (!provider) return this;
|
|
103
105
|
|
|
106
|
+
const hasExistingCredentials =
|
|
107
|
+
!!this.config.env || !!this.config.providerApiKey || !!this.config.usesProviderApiKey || !!this.config.piAuth;
|
|
108
|
+
|
|
104
109
|
if (this.customProvider) {
|
|
105
110
|
const apiKey = await this.promptForCustomProviderApiKey(this.customProvider);
|
|
106
|
-
if (apiKey
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
if (apiKey) {
|
|
112
|
+
this.config.providerApiKey = apiKey;
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
if (!hasExistingCredentials) {
|
|
116
|
+
if (this.customProvider.apiKey) {
|
|
110
117
|
this.config.usesProviderApiKey = true;
|
|
118
|
+
return this;
|
|
111
119
|
}
|
|
112
|
-
|
|
120
|
+
} else {
|
|
121
|
+
// Has existing credentials — fall through to let user choose "keep current" or update
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
const envKeys = providerUtil.requiredEnvKeysForProvider(provider, this.customProviders);
|
|
117
|
-
const envChoice = await this.ui.select("Credential env var", [
|
|
118
|
-
|
|
126
|
+
const envChoice = await this.ui.select("Credential env var", [
|
|
127
|
+
...envKeys,
|
|
128
|
+
"custom",
|
|
129
|
+
...(hasExistingCredentials ? ["keep current"] : []),
|
|
130
|
+
]);
|
|
131
|
+
if (!envChoice || envChoice === "keep current") return this;
|
|
119
132
|
|
|
120
133
|
const envName = envChoice === "custom" ? await this.prompt("Env var name", "PROVIDER_API_KEY").asText() : envChoice;
|
|
121
134
|
if (!envName) throw new Error("Env var name is required");
|
|
@@ -150,7 +163,12 @@ export class AccountConfigBuilder {
|
|
|
150
163
|
};
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Collect account configuration interactively.
|
|
168
|
+
* @param isEdit - When true, allows keeping existing credentials without re-entering them.
|
|
169
|
+
* Empty input for most fields will preserve the current value.
|
|
170
|
+
*/
|
|
171
|
+
async collect(isEdit = false): Promise<AccountConfig | undefined> {
|
|
154
172
|
await this.withProvider();
|
|
155
173
|
if (!this.config.provider) return undefined;
|
|
156
174
|
|
|
@@ -159,7 +177,13 @@ export class AccountConfigBuilder {
|
|
|
159
177
|
await this.withModel();
|
|
160
178
|
await this.withCredentials();
|
|
161
179
|
|
|
162
|
-
if (
|
|
180
|
+
if (
|
|
181
|
+
!isEdit &&
|
|
182
|
+
!this.config.env &&
|
|
183
|
+
!this.config.providerApiKey &&
|
|
184
|
+
!this.config.usesProviderApiKey &&
|
|
185
|
+
!this.config.piAuth
|
|
186
|
+
) {
|
|
163
187
|
this.ui.notify("No credentials configured. Account not saved.", "info");
|
|
164
188
|
return undefined;
|
|
165
189
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AccountSwitcher } from "@/runtime";
|
|
3
3
|
import type { AccountSwitcherContext } from "@/types";
|
|
4
4
|
import { COMMANDS } from "@/constants";
|
|
5
|
-
import { errorUtil, providerUtil } from "@/utils";
|
|
5
|
+
import { commandUtil, errorUtil, providerUtil } from "@/utils";
|
|
6
6
|
import { AccountCommand } from "./shared";
|
|
7
7
|
|
|
8
8
|
export const useSwitchAccountCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
|
|
@@ -20,7 +20,10 @@ class SwitchAccountCommand extends AccountCommand {
|
|
|
20
20
|
|
|
21
21
|
const active = this.runtime.getActiveAccount();
|
|
22
22
|
if (!active) {
|
|
23
|
-
ctx.ui.notify(
|
|
23
|
+
ctx.ui.notify(
|
|
24
|
+
`No active account. Use ${commandUtil.name(COMMANDS.accounts.list.name)} to activate one first.`,
|
|
25
|
+
"info",
|
|
26
|
+
);
|
|
24
27
|
return;
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { completeSimple, type Api, type Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AuthCredential, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AccountSwitcher } from "@/runtime";
|
|
4
|
+
import type { AccountConfig, AccountSwitcherContext, ProviderConfig, SecretSource } from "@/types";
|
|
5
|
+
import { COMMANDS } from "@/constants";
|
|
6
|
+
import { accountUtil, commonUtil, errorUtil, providerUtil, uiUtil } from "@/utils";
|
|
7
|
+
import { AccountCommand } from "./shared";
|
|
8
|
+
|
|
9
|
+
export const useVerifyAccountsCommand = (pi: ExtensionAPI, runtime: AccountSwitcher) => {
|
|
10
|
+
new VerifyAccountsCommand(pi, runtime).register();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type VerifyTestPlan = {
|
|
14
|
+
secrets: boolean;
|
|
15
|
+
ping: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type CheckResult = {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
line: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type AccountVerifyReport = {
|
|
24
|
+
account: AccountConfig;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
lines: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TEST_OPTIONS = {
|
|
30
|
+
secrets: "Verify secrets",
|
|
31
|
+
ping: "Send ping",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
const MAX_PARALLEL_VERIFY = 3;
|
|
35
|
+
|
|
36
|
+
class VerifyAccountsCommand extends AccountCommand {
|
|
37
|
+
constructor(pi: ExtensionAPI, runtime: AccountSwitcher) {
|
|
38
|
+
super(pi, runtime, COMMANDS.accounts.verify);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async handler(ctx: AccountSwitcherContext, args?: string): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
const accounts = await this.loadAccounts(ctx);
|
|
44
|
+
if (!accounts) return;
|
|
45
|
+
|
|
46
|
+
const parts = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
47
|
+
const verifyAll = parts.includes("all");
|
|
48
|
+
const pingArg = parts.includes("ping") || parts.includes("probe") || parts.includes("connect");
|
|
49
|
+
const targets = verifyAll
|
|
50
|
+
? accounts
|
|
51
|
+
: await this.pickGroupedAccounts(ctx, accounts, pingArg ? "Verify and ping accounts" : "Verify accounts");
|
|
52
|
+
|
|
53
|
+
if (!targets || targets.length === 0) return;
|
|
54
|
+
|
|
55
|
+
const plan = pingArg ? { secrets: true, ping: true } : await this.pickTestPlan(ctx);
|
|
56
|
+
if (!plan) return;
|
|
57
|
+
|
|
58
|
+
const selectedTests = this.formatSelectedTests(plan);
|
|
59
|
+
ctx.ui.notify(
|
|
60
|
+
`Testing ${targets.length} account${targets.length === 1 ? "" : "s"} (${selectedTests})...`,
|
|
61
|
+
"info",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const concurrency = plan.ping ? 1 : MAX_PARALLEL_VERIFY;
|
|
65
|
+
const reports = await commonUtil.runWithConcurrency(targets, concurrency, (account) =>
|
|
66
|
+
this.verifyAccount(ctx, account, plan),
|
|
67
|
+
);
|
|
68
|
+
const failed = reports.filter((report) => !report.ok).length;
|
|
69
|
+
|
|
70
|
+
ctx.ui.setEditorText(this.formatReport(plan, reports));
|
|
71
|
+
ctx.ui.notify(
|
|
72
|
+
`Finished testing ${targets.length} account${targets.length === 1 ? "" : "s"}${
|
|
73
|
+
failed ? ` — ${failed} failed` : " — all checks passed"
|
|
74
|
+
}.`,
|
|
75
|
+
failed ? "warning" : "info",
|
|
76
|
+
);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
ctx.ui.notify(`accounts:verify failed: ${errorUtil.format(error)}`, "error");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async pickTestPlan(ctx: AccountSwitcherContext): Promise<VerifyTestPlan | undefined> {
|
|
83
|
+
const selected = await uiUtil.multiSelect(
|
|
84
|
+
ctx.ui,
|
|
85
|
+
"What should be tested?",
|
|
86
|
+
[TEST_OPTIONS.secrets, TEST_OPTIONS.ping],
|
|
87
|
+
[true, false],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!selected) return undefined;
|
|
91
|
+
|
|
92
|
+
const plan = {
|
|
93
|
+
secrets: selected.includes(TEST_OPTIONS.secrets),
|
|
94
|
+
ping: selected.includes(TEST_OPTIONS.ping),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (!plan.secrets && !plan.ping) {
|
|
98
|
+
ctx.ui.notify("No tests selected.", "info");
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return plan;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async verifyAccount(
|
|
106
|
+
ctx: AccountSwitcherContext,
|
|
107
|
+
account: AccountConfig,
|
|
108
|
+
plan: VerifyTestPlan,
|
|
109
|
+
): Promise<AccountVerifyReport> {
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
let ok = true;
|
|
112
|
+
|
|
113
|
+
if (plan.secrets) {
|
|
114
|
+
let anyChecked = false;
|
|
115
|
+
|
|
116
|
+
if (account.piAuth) {
|
|
117
|
+
lines.push("✓ secrets: using stored OAuth/piAuth credentials");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (account.usesProviderApiKey && !account.providerApiKey) {
|
|
121
|
+
lines.push("✓ secrets: using provider config apiKey");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const secretChecks: Array<[string, SecretSource]> = [];
|
|
125
|
+
if (account.providerApiKey) secretChecks.push(["providerApiKey", account.providerApiKey]);
|
|
126
|
+
if (!account.piAuth && account.env) {
|
|
127
|
+
for (const [envName, source] of Object.entries(account.env)) secretChecks.push([envName, source]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (secretChecks.length > 0) {
|
|
131
|
+
const results = await Promise.all(secretChecks.map(([key, source]) => this.verifySecret(key, source)));
|
|
132
|
+
for (const result of results) {
|
|
133
|
+
lines.push(result.line);
|
|
134
|
+
ok &&= result.ok;
|
|
135
|
+
}
|
|
136
|
+
} else if (!account.piAuth && !account.usesProviderApiKey) {
|
|
137
|
+
lines.push("ℹ secrets: no secrets configured");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (plan.ping) {
|
|
142
|
+
const result = await this.pingAccount(ctx, account);
|
|
143
|
+
lines.push(result.line);
|
|
144
|
+
ok &&= result.ok;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { account, ok, lines };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async pingAccount(ctx: AccountSwitcherContext, account: AccountConfig): Promise<CheckResult> {
|
|
151
|
+
const prefix = `[${account.label}]`;
|
|
152
|
+
const authProvider = this.resolveAuthProvider(account);
|
|
153
|
+
const model = this.resolveProbeModel(ctx, account, authProvider);
|
|
154
|
+
if (!model) {
|
|
155
|
+
return { ok: false, line: `✗ ping: skipped — no model found for provider ${authProvider}` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const envBackup = new Map<string, string | undefined>();
|
|
159
|
+
let authBackup: AuthCredential | undefined;
|
|
160
|
+
let hadAuth = false;
|
|
161
|
+
let providerToRestore: ProviderConfig | undefined;
|
|
162
|
+
|
|
163
|
+
let requestAuth!: Awaited<ReturnType<typeof ctx.modelRegistry.getApiKeyAndHeaders>>;
|
|
164
|
+
try {
|
|
165
|
+
if (!account.piAuth && account.env) {
|
|
166
|
+
const resolved = await accountUtil.resolveAccountEnv(account);
|
|
167
|
+
for (const [envName, value] of resolved) {
|
|
168
|
+
envBackup.set(envName, process.env[envName]);
|
|
169
|
+
process.env[envName] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (account.piAuth) {
|
|
174
|
+
hadAuth = ctx.modelRegistry.authStorage.has(authProvider);
|
|
175
|
+
authBackup = ctx.modelRegistry.authStorage.get(authProvider);
|
|
176
|
+
ctx.modelRegistry.authStorage.set(authProvider, account.piAuth.entry);
|
|
177
|
+
ctx.modelRegistry.authStorage.reload();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (account.providerApiKey) {
|
|
181
|
+
const provider = providerUtil.findProvider(account.provider, this.runtime.getProviders());
|
|
182
|
+
if (provider) {
|
|
183
|
+
providerToRestore = provider;
|
|
184
|
+
const apiKey = await accountUtil.resolveSecret(account.providerApiKey);
|
|
185
|
+
if (!apiKey) throw new Error("Resolved empty providerApiKey for ping");
|
|
186
|
+
this.runtime.registerProvider({ ...provider, apiKey });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
requestAuth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
191
|
+
if (!requestAuth.ok) throw new Error(requestAuth.error);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return { ok: false, line: `✗ ping: unable to prepare credentials — ${errorUtil.format(err)}` };
|
|
194
|
+
} finally {
|
|
195
|
+
for (const [envName, previous] of envBackup) {
|
|
196
|
+
if (previous === undefined) delete process.env[envName];
|
|
197
|
+
else process.env[envName] = previous;
|
|
198
|
+
}
|
|
199
|
+
if (account.piAuth) {
|
|
200
|
+
if (hadAuth && authBackup) ctx.modelRegistry.authStorage.set(authProvider, authBackup);
|
|
201
|
+
else ctx.modelRegistry.authStorage.remove(authProvider);
|
|
202
|
+
ctx.modelRegistry.authStorage.reload();
|
|
203
|
+
}
|
|
204
|
+
if (providerToRestore) {
|
|
205
|
+
this.runtime.registerProvider(providerToRestore);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
ctx.ui.notify(`${prefix} ping: sending request via ${model.provider}/${model.id}...`, "info");
|
|
211
|
+
|
|
212
|
+
const response = await completeSimple(
|
|
213
|
+
model,
|
|
214
|
+
{
|
|
215
|
+
systemPrompt: "You are a health-check endpoint. Follow the user instruction exactly.",
|
|
216
|
+
messages: [
|
|
217
|
+
{
|
|
218
|
+
role: "user",
|
|
219
|
+
content: "Health check: reply with exactly OK.",
|
|
220
|
+
timestamp: Date.now(),
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
apiKey: requestAuth.apiKey,
|
|
226
|
+
headers: requestAuth.headers,
|
|
227
|
+
maxTokens: 16,
|
|
228
|
+
timeoutMs: 30_000,
|
|
229
|
+
maxRetries: 0,
|
|
230
|
+
reasoning: "minimal",
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (response.stopReason === "error") throw new Error(response.errorMessage ?? "model returned an error");
|
|
235
|
+
const text = response.content.find((block) => block.type === "text")?.text?.trim();
|
|
236
|
+
return { ok: true, line: `✓ ping: OK via ${model.provider}/${model.id}${text ? ` — ${text}` : ""}` };
|
|
237
|
+
} catch (err) {
|
|
238
|
+
return { ok: false, line: `✗ ping: failed — ${errorUtil.format(err)}` };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private resolveAuthProvider(account: AccountConfig): string {
|
|
243
|
+
if (account.piAuth?.provider) return account.piAuth.provider;
|
|
244
|
+
const provider = providerUtil.findProvider(account.provider, this.runtime.getProviders());
|
|
245
|
+
return provider?.piAuthProvider ?? providerUtil.normalizeProvider(account.provider);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private resolveProbeModel(
|
|
249
|
+
ctx: AccountSwitcherContext,
|
|
250
|
+
account: AccountConfig,
|
|
251
|
+
authProvider: string,
|
|
252
|
+
): Model<Api> | undefined {
|
|
253
|
+
const providers = this.runtime.getProviders();
|
|
254
|
+
const normalized = providerUtil.normalizeProviderWithCustom(authProvider, providers);
|
|
255
|
+
if (account.model) {
|
|
256
|
+
const configured = ctx.modelRegistry.find(authProvider, account.model);
|
|
257
|
+
if (configured) return configured;
|
|
258
|
+
}
|
|
259
|
+
if (ctx.model && providerUtil.normalizeProviderWithCustom(ctx.model.provider, providers) === normalized) {
|
|
260
|
+
return ctx.model;
|
|
261
|
+
}
|
|
262
|
+
return ctx.modelRegistry
|
|
263
|
+
.getAll()
|
|
264
|
+
.find((model) => providerUtil.normalizeProviderWithCustom(model.provider, providers) === normalized);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async verifySecret(key: string, source: SecretSource): Promise<CheckResult> {
|
|
268
|
+
try {
|
|
269
|
+
const value = await accountUtil.resolveSecret(source);
|
|
270
|
+
if (!value) throw new Error("resolved to empty value");
|
|
271
|
+
return { ok: true, line: `✓ secrets: ${key} OK` };
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return { ok: false, line: `✗ secrets: ${key} failed — ${errorUtil.format(err)}` };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private formatReport(plan: VerifyTestPlan, reports: AccountVerifyReport[]): string {
|
|
278
|
+
const selectedTests = this.formatSelectedTests(plan);
|
|
279
|
+
const passed = reports.filter((report) => report.ok).length;
|
|
280
|
+
|
|
281
|
+
return [
|
|
282
|
+
"Account verify results",
|
|
283
|
+
"",
|
|
284
|
+
`Tests: ${selectedTests}`,
|
|
285
|
+
`Accounts: ${reports.length} (${passed} passed, ${reports.length - passed} failed)`,
|
|
286
|
+
"",
|
|
287
|
+
...reports.flatMap((report) => [
|
|
288
|
+
`${report.ok ? "✓" : "✗"} ${report.account.label}`,
|
|
289
|
+
...report.lines.map((line) => ` ${line}`),
|
|
290
|
+
"",
|
|
291
|
+
]),
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private formatSelectedTests(plan: VerifyTestPlan): string {
|
|
296
|
+
return [plan.secrets ? "secrets" : undefined, plan.ping ? "ping" : undefined].filter(Boolean).join(" + ");
|
|
297
|
+
}
|
|
298
|
+
}
|