@hienlh/ppm 0.7.8 → 0.7.10

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +46 -0
  3. package/LICENSE +21 -0
  4. package/README.md +34 -1
  5. package/bun.lock +1 -0
  6. package/dist/web/assets/ai-settings-section-BxCMGg-I.js +1 -0
  7. package/dist/web/assets/chat-tab-R_8ZfOG8.js +7 -0
  8. package/dist/web/assets/{code-editor-1FNaZKfA.js → code-editor-BbhIHbts.js} +1 -1
  9. package/dist/web/assets/{database-viewer-Hso-EwQH.js → database-viewer-BJYmlnr2.js} +1 -1
  10. package/dist/web/assets/{diff-viewer-BG2UNjTZ.js → diff-viewer-CS-wesGq.js} +1 -1
  11. package/dist/web/assets/{git-graph-DK_yDfWe.js → git-graph-B9eaNltz.js} +1 -1
  12. package/dist/web/assets/index-qElHXk-7.js +28 -0
  13. package/dist/web/assets/index-sMxUHxFZ.css +2 -0
  14. package/dist/web/assets/input-CVIzrYsH.js +41 -0
  15. package/dist/web/assets/keybindings-store-DrBQMVKg.js +1 -0
  16. package/dist/web/assets/{markdown-renderer-Xe_wjdJH.js → markdown-renderer-DpIu7iOT.js} +1 -1
  17. package/dist/web/assets/{postgres-viewer-CguN1z3q.js → postgres-viewer-B5-tRXE2.js} +1 -1
  18. package/dist/web/assets/settings-tab-3-ewawy0.js +1 -0
  19. package/dist/web/assets/{sqlite-viewer-VrZiiegZ.js → sqlite-viewer-CfIer2x_.js} +1 -1
  20. package/dist/web/assets/{terminal-tab-CabMjIRO.js → terminal-tab-qJxp0iOK.js} +2 -2
  21. package/dist/web/index.html +4 -4
  22. package/dist/web/sw.js +1 -1
  23. package/docs/codebase-summary.md +16 -5
  24. package/docs/system-architecture.md +20 -2
  25. package/package.json +4 -1
  26. package/src/lib/account-crypto.ts +53 -0
  27. package/src/providers/claude-agent-sdk.ts +77 -3
  28. package/src/server/index.ts +8 -0
  29. package/src/server/routes/accounts.ts +165 -0
  30. package/src/server/routes/chat.ts +2 -0
  31. package/src/services/account-selector.service.ts +109 -0
  32. package/src/services/account.service.ts +411 -0
  33. package/src/services/claude-usage.service.ts +186 -124
  34. package/src/services/db.service.ts +117 -3
  35. package/src/types/chat.ts +2 -0
  36. package/src/web/app.tsx +0 -4
  37. package/src/web/components/chat/chat-history-bar.tsx +3 -0
  38. package/src/web/components/chat/usage-badge.tsx +86 -12
  39. package/src/web/components/settings/accounts-settings-section.tsx +358 -0
  40. package/src/web/components/settings/settings-tab.tsx +11 -0
  41. package/src/web/components/ui/badge.tsx +36 -0
  42. package/src/web/components/ui/switch.tsx +27 -0
  43. package/src/web/hooks/use-usage.ts +1 -1
  44. package/src/web/lib/api-settings.ts +65 -0
  45. package/dist/web/assets/ai-settings-section-ByRvOONz.js +0 -1
  46. package/dist/web/assets/chat-tab-DLfy6CBX.js +0 -7
  47. package/dist/web/assets/index-4pPCbWJp.css +0 -2
  48. package/dist/web/assets/index-DaQYRomz.js +0 -29
  49. package/dist/web/assets/input-P_K5CUiy.js +0 -41
  50. package/dist/web/assets/keybindings-store-xe6f5O18.js +0 -1
  51. package/dist/web/assets/settings-tab-CHONXRsW.js +0 -1
  52. package/src/web/hooks/use-health-check.ts +0 -95
@@ -0,0 +1,109 @@
1
+ import { accountService, type AccountWithTokens } from "./account.service.ts";
2
+ import { getConfigValue, setConfigValue } from "./db.service.ts";
3
+
4
+ export type AccountStrategy = "round-robin" | "fill-first";
5
+
6
+ const STRATEGY_CONFIG_KEY = "account_strategy";
7
+ const MAX_RETRY_CONFIG_KEY = "account_max_retry";
8
+
9
+ const BACKOFF_BASE_MS = 1_000;
10
+ const BACKOFF_MAX_MS = 30 * 60_000;
11
+
12
+ class AccountSelectorService {
13
+ private cursor = 0;
14
+ private retryCounts = new Map<string, number>();
15
+ private _lastPickedId: string | null = null;
16
+
17
+ /** ID of the last account returned by next() */
18
+ get lastPickedId(): string | null {
19
+ return this._lastPickedId;
20
+ }
21
+
22
+ getStrategy(): AccountStrategy {
23
+ return (getConfigValue(STRATEGY_CONFIG_KEY) as AccountStrategy) ?? "round-robin";
24
+ }
25
+
26
+ setStrategy(strategy: AccountStrategy): void {
27
+ setConfigValue(STRATEGY_CONFIG_KEY, strategy);
28
+ }
29
+
30
+ getMaxRetry(): number {
31
+ const v = getConfigValue(MAX_RETRY_CONFIG_KEY);
32
+ return v ? parseInt(v, 10) : 0;
33
+ }
34
+
35
+ setMaxRetry(n: number): void {
36
+ setConfigValue(MAX_RETRY_CONFIG_KEY, String(n));
37
+ }
38
+
39
+ /**
40
+ * Pick next available account (skips cooldown/disabled).
41
+ * Returns null if no active accounts available.
42
+ */
43
+ next(): AccountWithTokens | null {
44
+ const now = Math.floor(Date.now() / 1000);
45
+ const allAccounts = accountService.list();
46
+
47
+ // Clear expired cooldowns
48
+ for (const acc of allAccounts) {
49
+ if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
50
+ accountService.setEnabled(acc.id);
51
+ this.retryCounts.delete(acc.id);
52
+ }
53
+ }
54
+
55
+ const active = accountService.list().filter((a) => a.status === "active");
56
+ if (active.length === 0) return null;
57
+
58
+ let pickedId: string;
59
+ if (this.getStrategy() === "fill-first") {
60
+ const sorted = [...active].sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt);
61
+ pickedId = sorted[0].id;
62
+ } else {
63
+ // Round-robin
64
+ this.cursor = this.cursor % active.length;
65
+ pickedId = active[this.cursor].id;
66
+ this.cursor = (this.cursor + 1) % active.length;
67
+ }
68
+ this._lastPickedId = pickedId;
69
+ return accountService.getWithTokens(pickedId);
70
+ }
71
+
72
+ /** Called when account receives 429 — apply exponential backoff */
73
+ onRateLimit(accountId: string): void {
74
+ const retries = (this.retryCounts.get(accountId) ?? 0) + 1;
75
+ this.retryCounts.set(accountId, retries);
76
+ const backoffMs = Math.min(BACKOFF_BASE_MS * Math.pow(2, retries - 1), BACKOFF_MAX_MS);
77
+ const cooldownUntilMs = Date.now() + backoffMs;
78
+ accountService.setCooldown(accountId, cooldownUntilMs);
79
+ console.log(`[accounts] ${accountId} rate limited — cooldown ${Math.round(backoffMs / 1000)}s (retry #${retries})`);
80
+ }
81
+
82
+ /** Called when 401 Unauthorized — disable account */
83
+ onAuthError(accountId: string): void {
84
+ console.log(`[accounts] ${accountId} auth error — disabling account`);
85
+ accountService.setDisabled(accountId);
86
+ this.retryCounts.delete(accountId);
87
+ }
88
+
89
+ /** Called on successful request — reset retry count + track usage */
90
+ onSuccess(accountId: string): void {
91
+ this.retryCounts.delete(accountId);
92
+ accountService.trackUsage(accountId);
93
+ }
94
+
95
+ /** How many accounts are active or have expired cooldowns right now */
96
+ activeCount(): number {
97
+ const now = Math.floor(Date.now() / 1000);
98
+ return accountService.list().filter(
99
+ (a) => a.status === "active" || (a.status === "cooldown" && (a.cooldownUntil ?? 0) <= now),
100
+ ).length;
101
+ }
102
+
103
+ /** True if multi-account mode is enabled (≥1 account in DB) */
104
+ isEnabled(): boolean {
105
+ return accountService.list().length > 0;
106
+ }
107
+ }
108
+
109
+ export const accountSelector = new AccountSelectorService();
@@ -0,0 +1,411 @@
1
+ import { randomUUID, createHash, randomBytes } from "node:crypto";
2
+ import { encrypt, decrypt } from "../lib/account-crypto.ts";
3
+ import {
4
+ getAccounts,
5
+ getAccountById,
6
+ insertAccount,
7
+ updateAccount,
8
+ deleteAccount,
9
+ incrementAccountRequests,
10
+ type AccountRow,
11
+ } from "./db.service.ts";
12
+
13
+ export interface Account {
14
+ id: string;
15
+ label: string | null;
16
+ email: string | null;
17
+ expiresAt: number | null;
18
+ status: "active" | "cooldown" | "disabled";
19
+ cooldownUntil: number | null;
20
+ priority: number;
21
+ totalRequests: number;
22
+ lastUsedAt: number | null;
23
+ createdAt: number;
24
+ }
25
+
26
+ export interface AccountWithTokens extends Account {
27
+ accessToken: string;
28
+ refreshToken: string;
29
+ }
30
+
31
+ const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
32
+ const OAUTH_AUTH_URL = "https://claude.ai/oauth/authorize";
33
+ const OAUTH_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
34
+ const OAUTH_SCOPE = "org:create_api_key user:profile user:inference";
35
+
36
+ class AccountService {
37
+ private pendingStates = new Map<string, { verifier: string; createdAt: number }>();
38
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
39
+
40
+ private toAccount(row: AccountRow): Account {
41
+ return {
42
+ id: row.id,
43
+ label: row.label,
44
+ email: row.email,
45
+ expiresAt: row.expires_at,
46
+ status: row.status,
47
+ cooldownUntil: row.cooldown_until,
48
+ priority: row.priority,
49
+ totalRequests: row.total_requests,
50
+ lastUsedAt: row.last_used_at,
51
+ createdAt: row.created_at,
52
+ };
53
+ }
54
+
55
+ private toAccountWithTokens(row: AccountRow): AccountWithTokens {
56
+ return {
57
+ ...this.toAccount(row),
58
+ accessToken: decrypt(row.access_token),
59
+ refreshToken: decrypt(row.refresh_token),
60
+ };
61
+ }
62
+
63
+ list(): Account[] {
64
+ return getAccounts().map((r) => this.toAccount(r));
65
+ }
66
+
67
+ getWithTokens(id: string): AccountWithTokens | null {
68
+ const row = getAccountById(id);
69
+ return row ? this.toAccountWithTokens(row) : null;
70
+ }
71
+
72
+ add(params: {
73
+ email: string;
74
+ accessToken: string;
75
+ refreshToken: string;
76
+ expiresAt: number;
77
+ label?: string;
78
+ }): Account {
79
+ const id = randomUUID();
80
+ insertAccount({
81
+ id,
82
+ label: params.label ?? null,
83
+ email: params.email,
84
+ access_token: encrypt(params.accessToken),
85
+ refresh_token: encrypt(params.refreshToken),
86
+ expires_at: params.expiresAt,
87
+ status: "active",
88
+ cooldown_until: null,
89
+ priority: 0,
90
+ total_requests: 0,
91
+ last_used_at: null,
92
+ });
93
+ return this.toAccount(getAccountById(id)!);
94
+ }
95
+
96
+ async verifyToken(token: string): Promise<{
97
+ valid: boolean;
98
+ email?: string;
99
+ orgName?: string;
100
+ subscriptionType?: string;
101
+ authMethod?: string;
102
+ }> {
103
+ const isOAuth = token.startsWith("sk-ant-oat");
104
+
105
+ if (isOAuth) {
106
+ // Verify via usage API — 200/429 = valid, 401/403 = invalid
107
+ try {
108
+ const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
109
+ headers: {
110
+ Accept: "application/json",
111
+ Authorization: `Bearer ${token}`,
112
+ "anthropic-beta": "oauth-2025-04-20",
113
+ "User-Agent": "ppm/1.0",
114
+ },
115
+ signal: AbortSignal.timeout(10_000),
116
+ });
117
+ // 200 = valid, 429 = rate limited but valid token
118
+ if (res.status === 200 || res.status === 429) {
119
+ return { valid: true, authMethod: "oauth_token" };
120
+ }
121
+ return { valid: false };
122
+ } catch {
123
+ return { valid: false };
124
+ }
125
+ }
126
+
127
+ // API key: verify via claude auth status
128
+ try {
129
+ const proc = Bun.spawn(["claude", "auth", "status"], {
130
+ env: { ...process.env, ANTHROPIC_API_KEY: token, CLAUDE_CODE_OAUTH_TOKEN: "" },
131
+ stdout: "pipe",
132
+ stderr: "pipe",
133
+ });
134
+ const stdout = await new Response(proc.stdout).text();
135
+ await proc.exited;
136
+ const info = JSON.parse(stdout) as {
137
+ loggedIn?: boolean;
138
+ email?: string;
139
+ orgName?: string;
140
+ subscriptionType?: string;
141
+ authMethod?: string;
142
+ };
143
+ if (!info.loggedIn) return { valid: false };
144
+ return {
145
+ valid: true,
146
+ email: info.email,
147
+ orgName: info.orgName,
148
+ subscriptionType: info.subscriptionType,
149
+ authMethod: info.authMethod ?? "api_key",
150
+ };
151
+ } catch {
152
+ return { valid: false };
153
+ }
154
+ }
155
+
156
+ async addManual(params: { apiKey: string; label: string | null }): Promise<Account> {
157
+ const info = await this.verifyToken(params.apiKey);
158
+ if (!info.valid) throw new Error("Invalid token — could not authenticate");
159
+ const id = randomUUID();
160
+ const email = info.email ?? null;
161
+ // Auto-generate label: orgName (subscription) > authMethod-based > user-provided > fallback
162
+ let label = params.label;
163
+ if (!label) {
164
+ if (info.orgName) {
165
+ label = `${info.orgName}${info.subscriptionType ? ` (${info.subscriptionType})` : ""}`;
166
+ } else if (info.authMethod === "oauth_token") {
167
+ label = `Claude Pro/Max`;
168
+ } else if (info.authMethod === "api_key" || params.apiKey.startsWith("sk-ant-api")) {
169
+ label = "API Key";
170
+ } else {
171
+ label = `Account ${this.list().length + 1}`;
172
+ }
173
+ }
174
+ insertAccount({
175
+ id,
176
+ label,
177
+ email,
178
+ access_token: encrypt(params.apiKey),
179
+ refresh_token: encrypt(""),
180
+ expires_at: null,
181
+ status: "active",
182
+ cooldown_until: null,
183
+ priority: 0,
184
+ total_requests: 0,
185
+ last_used_at: null,
186
+ });
187
+ return this.toAccount(getAccountById(id)!);
188
+ }
189
+
190
+ updateTokens(id: string, accessToken: string, refreshToken: string, expiresAt: number): void {
191
+ updateAccount(id, {
192
+ access_token: encrypt(accessToken),
193
+ refresh_token: encrypt(refreshToken),
194
+ expires_at: expiresAt,
195
+ status: "active",
196
+ cooldown_until: null,
197
+ });
198
+ }
199
+
200
+ setCooldown(id: string, untilMs: number): void {
201
+ updateAccount(id, {
202
+ status: "cooldown",
203
+ cooldown_until: Math.floor(untilMs / 1000),
204
+ });
205
+ }
206
+
207
+ setDisabled(id: string): void {
208
+ updateAccount(id, { status: "disabled" });
209
+ }
210
+
211
+ setEnabled(id: string): void {
212
+ updateAccount(id, { status: "active", cooldown_until: null });
213
+ }
214
+
215
+ remove(id: string): void {
216
+ deleteAccount(id);
217
+ }
218
+
219
+ trackUsage(id: string): void {
220
+ incrementAccountRequests(id);
221
+ updateAccount(id, { last_used_at: Math.floor(Date.now() / 1000) });
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // OAuth PKCE helpers
226
+ // ---------------------------------------------------------------------------
227
+
228
+ private generatePkce(): { verifier: string; challenge: string } {
229
+ const verifier = randomBytes(32).toString("base64url");
230
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
231
+ return { verifier, challenge };
232
+ }
233
+
234
+ private cleanExpiredStates(): void {
235
+ const cutoff = Date.now() - 10 * 60 * 1000;
236
+ for (const [state, val] of this.pendingStates) {
237
+ if (val.createdAt < cutoff) this.pendingStates.delete(state);
238
+ }
239
+ }
240
+
241
+ startOAuthFlow(redirectUri: string): string {
242
+ this.cleanExpiredStates();
243
+ const { verifier, challenge } = this.generatePkce();
244
+ const state = randomBytes(16).toString("hex");
245
+ this.pendingStates.set(state, { verifier, createdAt: Date.now() });
246
+
247
+ const params = new URLSearchParams({
248
+ response_type: "code",
249
+ client_id: OAUTH_CLIENT_ID,
250
+ redirect_uri: redirectUri,
251
+ scope: OAUTH_SCOPE,
252
+ state,
253
+ code_challenge: challenge,
254
+ code_challenge_method: "S256",
255
+ });
256
+ return `${OAUTH_AUTH_URL}?${params}`;
257
+ }
258
+
259
+ async completeOAuthFlow(code: string, state: string, redirectUri: string): Promise<Account> {
260
+ const pending = this.pendingStates.get(state);
261
+ if (!pending) throw new Error("Invalid or expired OAuth state");
262
+ this.pendingStates.delete(state);
263
+
264
+ const tokens = await this.exchangeCode(code, pending.verifier, redirectUri);
265
+ return this.add({
266
+ email: tokens.email,
267
+ accessToken: tokens.accessToken,
268
+ refreshToken: tokens.refreshToken,
269
+ expiresAt: tokens.expiresAt,
270
+ });
271
+ }
272
+
273
+ async exchangeCode(code: string, verifier: string, redirectUri: string): Promise<{
274
+ accessToken: string;
275
+ refreshToken: string;
276
+ expiresAt: number;
277
+ email: string;
278
+ }> {
279
+ const res = await fetch(OAUTH_TOKEN_URL, {
280
+ method: "POST",
281
+ headers: { "Content-Type": "application/json" },
282
+ body: JSON.stringify({
283
+ grant_type: "authorization_code",
284
+ client_id: OAUTH_CLIENT_ID,
285
+ code,
286
+ redirect_uri: redirectUri,
287
+ code_verifier: verifier,
288
+ }),
289
+ });
290
+ if (!res.ok) {
291
+ const text = await res.text();
292
+ throw new Error(`OAuth token exchange failed: ${res.status} ${text}`);
293
+ }
294
+ const data = await res.json() as {
295
+ access_token: string;
296
+ refresh_token: string;
297
+ expires_in: number;
298
+ account?: { email_address?: string };
299
+ };
300
+ return {
301
+ accessToken: data.access_token,
302
+ refreshToken: data.refresh_token,
303
+ expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
304
+ email: data.account?.email_address ?? "",
305
+ };
306
+ }
307
+
308
+ async refreshAccessToken(accountId: string): Promise<void> {
309
+ const account = this.getWithTokens(accountId);
310
+ if (!account) throw new Error(`Account ${accountId} not found`);
311
+ const res = await fetch(OAUTH_TOKEN_URL, {
312
+ method: "POST",
313
+ headers: { "Content-Type": "application/json" },
314
+ body: JSON.stringify({
315
+ grant_type: "refresh_token",
316
+ client_id: OAUTH_CLIENT_ID,
317
+ refresh_token: account.refreshToken,
318
+ }),
319
+ });
320
+ if (!res.ok) {
321
+ this.setDisabled(accountId);
322
+ throw new Error(`Token refresh failed for account ${accountId}: ${res.status}`);
323
+ }
324
+ const data = await res.json() as {
325
+ access_token: string;
326
+ refresh_token?: string;
327
+ expires_in: number;
328
+ };
329
+ this.updateTokens(
330
+ accountId,
331
+ data.access_token,
332
+ data.refresh_token ?? account.refreshToken,
333
+ Math.floor(Date.now() / 1000) + data.expires_in,
334
+ );
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Export / Import encrypted backup
339
+ // ---------------------------------------------------------------------------
340
+
341
+ exportEncrypted(): string {
342
+ // Export raw DB rows (tokens are already encrypted) as JSON
343
+ const rows = getAccounts();
344
+ return JSON.stringify(rows, null, 2);
345
+ }
346
+
347
+ importEncrypted(json: string): number {
348
+ const rows = JSON.parse(json) as AccountRow[];
349
+ if (!Array.isArray(rows)) throw new Error("Invalid backup format");
350
+ let count = 0;
351
+ for (const row of rows) {
352
+ if (!row.id || !row.access_token || !row.refresh_token) continue;
353
+ // Skip if account already exists
354
+ if (getAccountById(row.id)) continue;
355
+ insertAccount({
356
+ id: row.id,
357
+ label: row.label,
358
+ email: row.email,
359
+ access_token: row.access_token,
360
+ refresh_token: row.refresh_token,
361
+ expires_at: row.expires_at,
362
+ status: row.status ?? "active",
363
+ cooldown_until: row.cooldown_until,
364
+ priority: row.priority ?? 0,
365
+ total_requests: row.total_requests ?? 0,
366
+ last_used_at: row.last_used_at,
367
+ });
368
+ count++;
369
+ }
370
+ return count;
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Auto-refresh background timer
375
+ // ---------------------------------------------------------------------------
376
+
377
+ startAutoRefresh(): void {
378
+ if (this.refreshTimer) return;
379
+ const CHECK_INTERVAL_MS = 5 * 60_000;
380
+ const REFRESH_BUFFER_S = 5 * 60;
381
+
382
+ this.refreshTimer = setInterval(async () => {
383
+ const accounts = this.list();
384
+ const nowS = Math.floor(Date.now() / 1000);
385
+ for (const acc of accounts) {
386
+ if (acc.status === "disabled") continue;
387
+ if (!acc.expiresAt) continue;
388
+ if (acc.expiresAt - nowS > REFRESH_BUFFER_S) continue;
389
+ console.log(`[accounts] Auto-refreshing token for ${acc.email ?? acc.id}`);
390
+ try {
391
+ await this.refreshAccessToken(acc.id);
392
+ } catch (e) {
393
+ console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
394
+ }
395
+ }
396
+ }, CHECK_INTERVAL_MS);
397
+
398
+ if (typeof this.refreshTimer === "object" && this.refreshTimer !== null && "unref" in this.refreshTimer) {
399
+ (this.refreshTimer as NodeJS.Timeout).unref();
400
+ }
401
+ }
402
+
403
+ stopAutoRefresh(): void {
404
+ if (this.refreshTimer) {
405
+ clearInterval(this.refreshTimer);
406
+ this.refreshTimer = null;
407
+ }
408
+ }
409
+ }
410
+
411
+ export const accountService = new AccountService();