@hieplp/pi-account-switcher 0.2.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/INSTALL_AS_PI_PACKAGE.md +78 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/USAGE.md +446 -0
- package/package.json +53 -0
- package/src/commands/accounts/add.ts +63 -0
- package/src/commands/accounts/edit.ts +31 -0
- package/src/commands/accounts/index.ts +19 -0
- package/src/commands/accounts/list.ts +31 -0
- package/src/commands/accounts/oauth.ts +59 -0
- package/src/commands/accounts/remove.ts +40 -0
- package/src/commands/accounts/shared/base.ts +88 -0
- package/src/commands/accounts/shared/index.ts +3 -0
- package/src/commands/accounts/shared/prompts.ts +226 -0
- package/src/commands/accounts/shared/select.ts +37 -0
- package/src/commands/accounts/switch.ts +54 -0
- package/src/commands/base.ts +81 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/models/add.ts +30 -0
- package/src/commands/models/index.ts +13 -0
- package/src/commands/models/list.ts +45 -0
- package/src/commands/models/remove.ts +41 -0
- package/src/commands/models/shared/base.ts +44 -0
- package/src/commands/models/shared/index.ts +3 -0
- package/src/commands/models/shared/prompts.ts +36 -0
- package/src/commands/models/shared/select.ts +37 -0
- package/src/commands/providers/add.ts +28 -0
- package/src/commands/providers/edit.ts +29 -0
- package/src/commands/providers/index.ts +15 -0
- package/src/commands/providers/list.ts +38 -0
- package/src/commands/providers/remove.ts +46 -0
- package/src/commands/providers/shared/base.ts +30 -0
- package/src/commands/providers/shared/index.ts +3 -0
- package/src/commands/providers/shared/prompts.ts +172 -0
- package/src/commands/providers/shared/select.ts +24 -0
- package/src/commands/system/index.ts +7 -0
- package/src/commands/system/reset.ts +36 -0
- package/src/constants/commands.ts +66 -0
- package/src/constants/config.ts +6 -0
- package/src/constants/index.ts +4 -0
- package/src/constants/paths.ts +8 -0
- package/src/constants/providers.ts +36 -0
- package/src/extension.ts +21 -0
- package/src/index.ts +20 -0
- package/src/runtime/account-switcher-runtime.ts +194 -0
- package/src/runtime/account-switcher.ts +32 -0
- package/src/runtime/index.ts +11 -0
- package/src/schemas/accounts.ts +50 -0
- package/src/schemas/common.ts +3 -0
- package/src/schemas/config.ts +7 -0
- package/src/schemas/index.ts +4 -0
- package/src/schemas/providers.ts +57 -0
- package/src/services/accounts.ts +116 -0
- package/src/services/index.ts +4 -0
- package/src/services/models.ts +27 -0
- package/src/services/pi-auth.ts +23 -0
- package/src/services/providers.ts +123 -0
- package/src/storage/accounts.ts +109 -0
- package/src/storage/index.ts +4 -0
- package/src/storage/paths.ts +8 -0
- package/src/storage/pi-auth.ts +37 -0
- package/src/storage/providers.ts +85 -0
- package/src/storage/state.ts +43 -0
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +6 -0
- package/src/types/context.ts +3 -0
- package/src/types/index.ts +4 -0
- package/src/types/providers.ts +53 -0
- package/src/utils/accounts.ts +99 -0
- package/src/utils/common.ts +74 -0
- package/src/utils/errors.ts +16 -0
- package/src/utils/files.ts +25 -0
- package/src/utils/filterable-selector.ts +114 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/models.ts +76 -0
- package/src/utils/providers.ts +49 -0
- package/src/utils/ui.ts +49 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { type ProviderStore, useProviderStore } from "@/storage";
|
|
3
|
+
import type { ProviderConfig, ProviderModelConfig } from "@/types";
|
|
4
|
+
import { commonUtil } from "@/utils";
|
|
5
|
+
|
|
6
|
+
export interface ProviderService {
|
|
7
|
+
load(): Promise<void>;
|
|
8
|
+
getProviders(): ProviderConfig[];
|
|
9
|
+
addProvider(provider: ProviderConfig): Promise<void>;
|
|
10
|
+
editProvider(original: ProviderConfig, updated: ProviderConfig): Promise<void>;
|
|
11
|
+
removeProvider(provider: ProviderConfig): Promise<void>;
|
|
12
|
+
registerProviders(providers: ProviderConfig[]): void;
|
|
13
|
+
registerProvider(provider: ProviderConfig): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useProviderService(pi: ExtensionAPI, path: string): ProviderService {
|
|
17
|
+
return new ProviderServiceImpl(pi, useProviderStore(path));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ===============================================================================================
|
|
21
|
+
// Provider Service
|
|
22
|
+
// ===============================================================================================
|
|
23
|
+
|
|
24
|
+
class ProviderServiceImpl implements ProviderService {
|
|
25
|
+
private providers: ProviderConfig[] = [];
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly pi: ExtensionAPI,
|
|
29
|
+
private readonly store: ProviderStore,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
async load(): Promise<void> {
|
|
33
|
+
this.providers = await this.store.load();
|
|
34
|
+
this.registerProviders(this.providers);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getProviders(): ProviderConfig[] {
|
|
38
|
+
return this.providers;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async addProvider(provider: ProviderConfig): Promise<void> {
|
|
42
|
+
if (this.providers.some((p) => p.id === provider.id)) {
|
|
43
|
+
throw new Error(`Provider already exists: ${provider.id}`);
|
|
44
|
+
}
|
|
45
|
+
this.registerProvider(provider);
|
|
46
|
+
this.providers.push(provider);
|
|
47
|
+
await this.store.save(this.providers);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async editProvider(original: ProviderConfig, updated: ProviderConfig): Promise<void> {
|
|
51
|
+
const index = this.providers.findIndex((p) => p.id === original.id);
|
|
52
|
+
if (index === -1) throw new Error(`Provider not found: ${original.id}`);
|
|
53
|
+
if (updated.id !== original.id && this.providers.some((p) => p.id === updated.id)) {
|
|
54
|
+
throw new Error(`Provider already exists: ${updated.id}`);
|
|
55
|
+
}
|
|
56
|
+
this.providers[index] = updated;
|
|
57
|
+
await this.store.save(this.providers);
|
|
58
|
+
this.registerProvider(updated);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async removeProvider(provider: ProviderConfig): Promise<void> {
|
|
62
|
+
const index = this.providers.findIndex((p) => p.id === provider.id);
|
|
63
|
+
if (index === -1) throw new Error(`Provider not found: ${provider.id}`);
|
|
64
|
+
this.providers.splice(index, 1);
|
|
65
|
+
await this.store.save(this.providers);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
registerProviders(providers: ProviderConfig[]): void {
|
|
69
|
+
providers.forEach((provider) => this.registerProvider(provider));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
registerProvider(provider: ProviderConfig): void {
|
|
73
|
+
const config = this.toPiProvider(provider);
|
|
74
|
+
if (!config) return;
|
|
75
|
+
this.pi.registerProvider(
|
|
76
|
+
provider.id,
|
|
77
|
+
config as Parameters<ExtensionAPI["registerProvider"]>[1],
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private toPiProvider(provider: ProviderConfig): Record<string, unknown> | undefined {
|
|
82
|
+
if (
|
|
83
|
+
!provider.baseUrl &&
|
|
84
|
+
!provider.api &&
|
|
85
|
+
!provider.apiKey &&
|
|
86
|
+
!provider.models &&
|
|
87
|
+
!provider.headers &&
|
|
88
|
+
!provider.authHeader &&
|
|
89
|
+
!provider.compat
|
|
90
|
+
) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return commonUtil.omitUndefined({
|
|
95
|
+
name: provider.name ?? provider.label,
|
|
96
|
+
baseUrl: provider.baseUrl,
|
|
97
|
+
apiKey: provider.apiKey,
|
|
98
|
+
api: provider.api,
|
|
99
|
+
headers: provider.headers,
|
|
100
|
+
authHeader: provider.authHeader,
|
|
101
|
+
models: provider.models?.map((model) => this.toPiModel(provider, model)),
|
|
102
|
+
modelOverrides: provider.modelOverrides,
|
|
103
|
+
compat: provider.compat,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private toPiModel(
|
|
108
|
+
provider: ProviderConfig,
|
|
109
|
+
model: ProviderModelConfig,
|
|
110
|
+
): Record<string, unknown> | undefined {
|
|
111
|
+
return commonUtil.omitUndefined({
|
|
112
|
+
...model,
|
|
113
|
+
api: model.api ?? provider.api,
|
|
114
|
+
name: model.name ?? model.id,
|
|
115
|
+
reasoning: model.reasoning ?? false,
|
|
116
|
+
input: model.input ?? ["text"],
|
|
117
|
+
contextWindow: model.contextWindow ?? 128000,
|
|
118
|
+
maxTokens: model.maxTokens ?? 16384,
|
|
119
|
+
cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
120
|
+
compat: model.compat ?? provider.compat,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { configSchema } from "@/schemas";
|
|
3
|
+
import type { AccountConfig, AccountSwitcherConfig } from "@/types";
|
|
4
|
+
import { errorUtil, fileUtil } from "@/utils";
|
|
5
|
+
|
|
6
|
+
export interface AccountStore {
|
|
7
|
+
load(): Promise<AccountConfig[]>;
|
|
8
|
+
addAccount(account: AccountConfig): Promise<AccountConfig[]>;
|
|
9
|
+
replaceAccount(originalId: string, account: AccountConfig): Promise<AccountConfig[]>;
|
|
10
|
+
removeAccount(id: string): Promise<AccountConfig[]>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useAccountStore(path: string) {
|
|
14
|
+
return new AccountStoreImpl(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ===============================================================================================
|
|
18
|
+
// Account Store
|
|
19
|
+
// ===============================================================================================
|
|
20
|
+
|
|
21
|
+
class AccountStoreImpl implements AccountStore {
|
|
22
|
+
constructor(private readonly path: string) {}
|
|
23
|
+
|
|
24
|
+
async load(): Promise<AccountConfig[]> {
|
|
25
|
+
try {
|
|
26
|
+
const raw = await readFile(this.path, "utf8");
|
|
27
|
+
const parsed = configSchema.parse(JSON.parse(raw));
|
|
28
|
+
|
|
29
|
+
const { accounts } = parsed;
|
|
30
|
+
accountValidator.assertNoDuplicateAccounts(accounts);
|
|
31
|
+
accountValidator.assertAccountsHaveCredentials(accounts);
|
|
32
|
+
|
|
33
|
+
return accounts;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (fileUtil.isMissingFileError(error)) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Failed to load accounts at ${this.path}: ${errorUtil.format(error)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async addAccount(account: AccountConfig): Promise<AccountConfig[]> {
|
|
43
|
+
const accounts = await this.load();
|
|
44
|
+
accountValidator.assertAccountIdAvailable(accounts, account.id);
|
|
45
|
+
const next = [...accounts, account];
|
|
46
|
+
await this.save(next);
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async replaceAccount(originalId: string, account: AccountConfig): Promise<AccountConfig[]> {
|
|
51
|
+
const accounts = await this.load();
|
|
52
|
+
const index = accounts.findIndex((a) => a.id === originalId);
|
|
53
|
+
if (index === -1) throw new Error(`Account not found: ${originalId}`);
|
|
54
|
+
accountValidator.assertAccountIdAvailable(accounts, account.id, originalId);
|
|
55
|
+
const next = accounts.filter((a) => a.id !== originalId);
|
|
56
|
+
next.splice(index, 0, account);
|
|
57
|
+
await this.save(next);
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async removeAccount(id: string): Promise<AccountConfig[]> {
|
|
62
|
+
const accounts = await this.load();
|
|
63
|
+
const next = accounts.filter((a) => a.id !== id);
|
|
64
|
+
if (next.length === accounts.length) throw new Error(`Account not found: ${id}`);
|
|
65
|
+
await this.save(next);
|
|
66
|
+
return next;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async save(accounts: AccountConfig[]): Promise<void> {
|
|
70
|
+
const config: AccountSwitcherConfig = { accounts };
|
|
71
|
+
await fileUtil.writePrivateJson(this.path, config);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ===============================================================================================
|
|
76
|
+
// Account Validator
|
|
77
|
+
// ===============================================================================================
|
|
78
|
+
|
|
79
|
+
const accountValidator = {
|
|
80
|
+
assertNoDuplicateAccounts: (accounts: AccountConfig[]): void => {
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const duplicates = new Set<string>();
|
|
83
|
+
for (const { id } of accounts) {
|
|
84
|
+
if (seen.has(id)) duplicates.add(id);
|
|
85
|
+
else seen.add(id);
|
|
86
|
+
}
|
|
87
|
+
if (duplicates.size > 0) {
|
|
88
|
+
throw new Error(`Duplicate account ids: ${Array.from(duplicates).sort().join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
assertAccountIdAvailable: (accounts: AccountConfig[], id: string, originalId?: string): void => {
|
|
93
|
+
if (id === originalId) return;
|
|
94
|
+
if (accounts.some((account) => account.id === id)) {
|
|
95
|
+
throw new Error(`Account already exists: ${id}`);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
assertAccountsHaveCredentials: (accounts: AccountConfig[]): void => {
|
|
100
|
+
const missing = accounts
|
|
101
|
+
.filter(
|
|
102
|
+
(a) => (!a.env || Object.keys(a.env).length === 0) && !a.providerApiKey && !a.usesProviderApiKey && !a.piAuth,
|
|
103
|
+
)
|
|
104
|
+
.map((a) => a.id);
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
throw new Error(`Accounts missing credentials: ${missing.sort().join(", ")}`);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const APP_DIR = join(homedir(), ".pi", "account-switcher");
|
|
5
|
+
export const CONFIG_PATH = join(APP_DIR, "accounts.json");
|
|
6
|
+
export const PROVIDERS_PATH = join(APP_DIR, "providers.json");
|
|
7
|
+
export const STATE_PATH = join(APP_DIR, "state.json");
|
|
8
|
+
export const PI_AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { PI_AUTH_PATH } from "@/constants";
|
|
3
|
+
import type { PiAuthEntry } from "@/types";
|
|
4
|
+
import { fileUtil } from "@/utils";
|
|
5
|
+
|
|
6
|
+
type PiAuthFile = Record<string, PiAuthEntry>;
|
|
7
|
+
|
|
8
|
+
export interface PiAuthStore {
|
|
9
|
+
getEntry(provider: string): Promise<PiAuthEntry | undefined>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function usePiAuthStore(path = PI_AUTH_PATH): PiAuthStore {
|
|
13
|
+
return new PiAuthStoreImpl(path);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isOAuthEntry(entry: PiAuthEntry | undefined): boolean {
|
|
17
|
+
return entry?.type === "oauth";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class PiAuthStoreImpl implements PiAuthStore {
|
|
21
|
+
constructor(private readonly path: string) {}
|
|
22
|
+
|
|
23
|
+
async getEntry(provider: string): Promise<PiAuthEntry | undefined> {
|
|
24
|
+
const auth = await this.load();
|
|
25
|
+
return auth[provider];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async load(): Promise<PiAuthFile> {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await readFile(this.path, "utf8");
|
|
31
|
+
return JSON.parse(raw) as PiAuthFile;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (fileUtil.isMissingFileError(error)) return {};
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ProviderConfig } from "@/types";
|
|
2
|
+
import { commonUtil, errorUtil, fileUtil, providerUtil } from "@/utils";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { providerCatalogArraySchema, providerCatalogRecordSchema } from "@/schemas";
|
|
5
|
+
|
|
6
|
+
export interface ProviderStore {
|
|
7
|
+
load(): Promise<ProviderConfig[]>;
|
|
8
|
+
save(providers: ProviderConfig[]): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useProviderStore(path: string) {
|
|
12
|
+
return new ProviderStoreImpl(path);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ===============================================================================================
|
|
16
|
+
// Provider Store
|
|
17
|
+
// ===============================================================================================
|
|
18
|
+
|
|
19
|
+
class ProviderStoreImpl implements ProviderStore {
|
|
20
|
+
constructor(private readonly path: string) {}
|
|
21
|
+
|
|
22
|
+
async save(providers: ProviderConfig[]): Promise<void> {
|
|
23
|
+
await fileUtil.writePrivateJson(this.path, { providers });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async load(): Promise<ProviderConfig[]> {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(this.path, "utf8");
|
|
29
|
+
const json = JSON.parse(raw) as unknown;
|
|
30
|
+
const result = providerCatalogRecordSchema.safeParse(json);
|
|
31
|
+
if (result.success) return JsonParser.parseRecord(result.data.providers);
|
|
32
|
+
|
|
33
|
+
const arrayResult = providerCatalogArraySchema.safeParse(json);
|
|
34
|
+
if (arrayResult.success) return JsonParser.parseArray(arrayResult.data.providers);
|
|
35
|
+
|
|
36
|
+
throw new Error("providers.json must contain either a providers object or providers array");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (fileUtil.isMissingFileError(error)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Failed to load account switcher providers at ${this.path}: ${errorUtil.format(error)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ===============================================================================================
|
|
47
|
+
// Json Parser
|
|
48
|
+
// ===============================================================================================
|
|
49
|
+
|
|
50
|
+
type ProviderRecord = Record<string, Omit<ProviderConfig, "id"> & { id?: string }>;
|
|
51
|
+
|
|
52
|
+
class JsonParser {
|
|
53
|
+
static parseRecord(providers: ProviderRecord): ProviderConfig[] {
|
|
54
|
+
return Object.entries(providers).map(([id, provider]) => this.normalize({ ...provider, id } as ProviderConfig));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static parseArray(providers: ProviderConfig[]): ProviderConfig[] {
|
|
58
|
+
return providers.map((provider) => this.normalize(provider));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private static normalize(provider: ProviderConfig): ProviderConfig {
|
|
62
|
+
const id = providerUtil.normalizeProvider(provider.id);
|
|
63
|
+
|
|
64
|
+
const envKeys = commonUtil.unique([
|
|
65
|
+
...(provider.envKeys ?? []),
|
|
66
|
+
...(provider.apiKey && commonUtil.isLikelyEnvKey(provider.apiKey) ? [provider.apiKey] : []),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const aliases = commonUtil
|
|
70
|
+
.unique((provider.aliases ?? []).map(providerUtil.normalizeProvider))
|
|
71
|
+
.filter((alias) => alias !== id);
|
|
72
|
+
|
|
73
|
+
const api =
|
|
74
|
+
provider.api ?? (provider.baseUrl || provider.apiKey || provider.models ? "openai-completions" : undefined);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
...provider,
|
|
78
|
+
id,
|
|
79
|
+
label: provider.label ?? provider.name,
|
|
80
|
+
api,
|
|
81
|
+
aliases,
|
|
82
|
+
envKeys,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { STATE_PATH } from "@/constants";
|
|
4
|
+
import { fileUtil } from "@/utils";
|
|
5
|
+
|
|
6
|
+
const appStateSchema = z.object({
|
|
7
|
+
activeAccountId: z.string().optional(),
|
|
8
|
+
activeModelId: z.string().optional(),
|
|
9
|
+
activeModelProvider: z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export interface AppState {
|
|
13
|
+
activeAccountId?: string;
|
|
14
|
+
activeModelId?: string;
|
|
15
|
+
activeModelProvider?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StateStore {
|
|
19
|
+
load(): Promise<AppState>;
|
|
20
|
+
save(state: AppState): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useStateStore(path = STATE_PATH): StateStore {
|
|
24
|
+
return new StateStoreImpl(path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class StateStoreImpl implements StateStore {
|
|
28
|
+
constructor(private readonly path: string) {}
|
|
29
|
+
|
|
30
|
+
async load(): Promise<AppState> {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(this.path, "utf8");
|
|
33
|
+
return appStateSchema.parse(JSON.parse(raw));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (fileUtil.isMissingFileError(error)) return {};
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async save(state: AppState): Promise<void> {
|
|
41
|
+
await fileUtil.writePrivateJson(this.path, state);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ProviderId } from "./providers";
|
|
2
|
+
|
|
3
|
+
export type SecretSource =
|
|
4
|
+
| string
|
|
5
|
+
| { type: "literal"; value: string }
|
|
6
|
+
| { type: "env"; name: string }
|
|
7
|
+
| { type: "file"; path: string }
|
|
8
|
+
| { type: "command"; command: string }
|
|
9
|
+
| { type: "op"; reference: string };
|
|
10
|
+
|
|
11
|
+
export type PiAuthEntry =
|
|
12
|
+
| { type: "api_key"; key: string }
|
|
13
|
+
| ({
|
|
14
|
+
type: "oauth";
|
|
15
|
+
refresh: string;
|
|
16
|
+
access: string;
|
|
17
|
+
expires: number;
|
|
18
|
+
} & Record<string, unknown>);
|
|
19
|
+
|
|
20
|
+
export interface AccountConfig {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
provider: ProviderId;
|
|
24
|
+
/** API-key/env based account. */
|
|
25
|
+
env?: Record<string, SecretSource>;
|
|
26
|
+
/** Per-account override for a custom Pi model provider apiKey. */
|
|
27
|
+
providerApiKey?: SecretSource;
|
|
28
|
+
/** Uses apiKey from custom provider metadata. */
|
|
29
|
+
usesProviderApiKey?: boolean;
|
|
30
|
+
/** Optional model to switch to when this account is activated. */
|
|
31
|
+
model?: string;
|
|
32
|
+
/** Captured Pi /login credentials for built-in OAuth/subscription providers. */
|
|
33
|
+
piAuth?: {
|
|
34
|
+
provider: ProviderId;
|
|
35
|
+
entry: PiAuthEntry;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type ProviderId = string;
|
|
2
|
+
|
|
3
|
+
export type ProviderApi =
|
|
4
|
+
| "anthropic-messages"
|
|
5
|
+
| "openai-completions"
|
|
6
|
+
| "openai-responses"
|
|
7
|
+
| "azure-openai-responses"
|
|
8
|
+
| "openai-codex-responses"
|
|
9
|
+
| "mistral-conversations"
|
|
10
|
+
| "google-generative-ai"
|
|
11
|
+
| "google-vertex"
|
|
12
|
+
| "bedrock-converse-stream"
|
|
13
|
+
| string;
|
|
14
|
+
|
|
15
|
+
export interface ProviderModelConfig {
|
|
16
|
+
id: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
api?: ProviderApi;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
reasoning?: boolean;
|
|
21
|
+
input?: ("text" | "image")[];
|
|
22
|
+
contextWindow?: number;
|
|
23
|
+
maxTokens?: number;
|
|
24
|
+
cost?: {
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
cacheRead: number;
|
|
28
|
+
cacheWrite: number;
|
|
29
|
+
};
|
|
30
|
+
compat?: Record<string, unknown>;
|
|
31
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
32
|
+
headers?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProviderConfig {
|
|
36
|
+
id: ProviderId;
|
|
37
|
+
label?: string;
|
|
38
|
+
/** Alias for Pi provider display name when exported/registered. */
|
|
39
|
+
name?: string;
|
|
40
|
+
envKeys?: string[];
|
|
41
|
+
aliases?: string[];
|
|
42
|
+
/** Raw Pi auth provider id, when different from this provider's account id. */
|
|
43
|
+
piAuthProvider?: ProviderId;
|
|
44
|
+
/** Pi custom model provider config fields. */
|
|
45
|
+
baseUrl?: string;
|
|
46
|
+
api?: ProviderApi;
|
|
47
|
+
apiKey?: string;
|
|
48
|
+
headers?: Record<string, string>;
|
|
49
|
+
authHeader?: boolean;
|
|
50
|
+
compat?: Record<string, unknown>;
|
|
51
|
+
models?: ProviderModelConfig[];
|
|
52
|
+
modelOverrides?: Record<string, Partial<ProviderModelConfig>>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as piAi from "@mariozechner/pi-ai";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { AccountConfig, SecretSource } from "@/types";
|
|
5
|
+
import { commonUtil } from "./common";
|
|
6
|
+
import { fileUtil } from "./files";
|
|
7
|
+
import { providerUtil } from "./providers";
|
|
8
|
+
|
|
9
|
+
export const accountUtil = {
|
|
10
|
+
clearAccountEnv: async (account: AccountConfig, modelRegistry?: ModelRegistry): Promise<void> => {
|
|
11
|
+
const authProvider = account.piAuth?.provider ?? providerUtil.normalizeProvider(account.provider);
|
|
12
|
+
if (!account.piAuth && account.env) {
|
|
13
|
+
for (const envName of Object.keys(account.env)) {
|
|
14
|
+
delete process.env[envName];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
modelRegistry?.authStorage.removeRuntimeApiKey(authProvider);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
applyAccountEnv: async (
|
|
21
|
+
account: AccountConfig,
|
|
22
|
+
modelRegistry?: ModelRegistry,
|
|
23
|
+
authProviderOverride?: string,
|
|
24
|
+
): Promise<string[]> => {
|
|
25
|
+
if (account.piAuth) {
|
|
26
|
+
const authProvider = authProviderOverride ?? account.piAuth.provider;
|
|
27
|
+
modelRegistry?.authStorage.set(authProvider, account.piAuth.entry);
|
|
28
|
+
modelRegistry?.authStorage.reload();
|
|
29
|
+
closeCachedSessions();
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const resolved = await accountUtil.resolveAccountEnv(account);
|
|
34
|
+
return accountUtil.applyResolvedAccountEnv(account, resolved, modelRegistry, authProviderOverride);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
resolveAccountEnv: async (account: AccountConfig): Promise<Array<[string, string]>> => {
|
|
38
|
+
if (!account.env) return [];
|
|
39
|
+
|
|
40
|
+
const resolvedEntries: Array<[string, string]> = [];
|
|
41
|
+
for (const [envName, source] of Object.entries(account.env)) {
|
|
42
|
+
const value = await accountUtil.resolveSecret(source);
|
|
43
|
+
if (!value) throw new Error(`Resolved empty value for ${envName} in account ${account.id}`);
|
|
44
|
+
resolvedEntries.push([envName, value]);
|
|
45
|
+
}
|
|
46
|
+
return resolvedEntries;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
applyResolvedAccountEnv: (
|
|
50
|
+
account: AccountConfig,
|
|
51
|
+
resolvedEntries: Array<[string, string]>,
|
|
52
|
+
modelRegistry?: ModelRegistry,
|
|
53
|
+
authProviderOverride?: string,
|
|
54
|
+
): string[] => {
|
|
55
|
+
const authProvider = authProviderOverride ?? providerUtil.normalizeProvider(account.provider);
|
|
56
|
+
const applied: string[] = [];
|
|
57
|
+
for (const [envName, value] of resolvedEntries) {
|
|
58
|
+
process.env[envName] = value;
|
|
59
|
+
applied.push(envName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const firstValue = resolvedEntries[0]?.[1];
|
|
63
|
+
if (firstValue) modelRegistry?.authStorage.setRuntimeApiKey(authProvider, firstValue);
|
|
64
|
+
else modelRegistry?.authStorage.removeRuntimeApiKey(authProvider);
|
|
65
|
+
|
|
66
|
+
return applied;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
resolveSecret: async (source: SecretSource): Promise<string> => {
|
|
70
|
+
if (typeof source === "string") {
|
|
71
|
+
if (source.startsWith("op://")) return commonUtil.runOpRead(source);
|
|
72
|
+
return source;
|
|
73
|
+
}
|
|
74
|
+
switch (source.type) {
|
|
75
|
+
case "literal":
|
|
76
|
+
return source.value;
|
|
77
|
+
case "env": {
|
|
78
|
+
const value = process.env[source.name];
|
|
79
|
+
if (!value) throw new Error(`Environment variable ${source.name} is not set`);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
case "file":
|
|
83
|
+
return (await readFile(fileUtil.expandHome(source.path), "utf8")).trim();
|
|
84
|
+
case "command":
|
|
85
|
+
return commonUtil.runCommand(source.command);
|
|
86
|
+
case "op":
|
|
87
|
+
return commonUtil.runOpRead(source.reference);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function closeCachedSessions(): void {
|
|
93
|
+
const helpers = piAi as {
|
|
94
|
+
cleanupSessionResources?: () => void;
|
|
95
|
+
closeOpenAICodexWebSocketSessions?: () => void;
|
|
96
|
+
};
|
|
97
|
+
helpers.cleanupSessionResources?.();
|
|
98
|
+
helpers.closeOpenAICodexWebSocketSessions?.();
|
|
99
|
+
}
|