qwenproxy-cli 1.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.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. package/src/utils/types.ts +109 -0
@@ -0,0 +1,297 @@
1
+ import {
2
+ QwenAccount,
3
+ loadAccounts,
4
+ updateAccountCooldown,
5
+ } from "./accounts.ts";
6
+ import { getAccountsByPriority } from "./account-priority.ts";
7
+ import { formatCooldownUntil } from "./logger.ts";
8
+
9
+ let currentIndex = 0;
10
+
11
+ interface CooldownEntry {
12
+ until: number;
13
+ reason: string;
14
+ }
15
+
16
+ const cooldowns = new Map<string, CooldownEntry>();
17
+
18
+ /**
19
+ * Milliseconds until the next UTC midnight plus a safety margin. The Qwen
20
+ * daily quota resets at 00:00 UTC, so this is the correct "when is this
21
+ * account usable again" for a quota exhaust — regardless of the upstream
22
+ * "Wait about N hour(s)" hint (accurate mid-day, but rounds to ~24h near
23
+ * midnight when the real reset is minutes away).
24
+ */
25
+ export function computeQuotaCooldownMs(
26
+ nowMs: number,
27
+ marginMs = 5 * 60 * 1000,
28
+ ): number {
29
+ const nextMidnight = new Date(nowMs);
30
+ nextMidnight.setUTCHours(24, 0, 0, 0);
31
+ const targetMs = nextMidnight.getTime() - nowMs + marginMs;
32
+ // A daily quota cooldown must never exceed 24h (cap to 24h - 1m so it never spills over
33
+ // during the first marginMs window after 00:00 UTC).
34
+ const maxCooldownMs = 24 * 60 * 60 * 1000 - 60_000;
35
+ return Math.min(maxCooldownMs, Math.max(60_000, targetMs));
36
+ }
37
+
38
+ // The long-ago 24h blind fallback was the source of "treated available accounts
39
+ // as unavailable": when no explicit duration was given (e.g. a quota exhaust
40
+ // without the wait hint) the account was parked for a full day even though the
41
+ // Qwen daily quota resets at the next UTC midnight. Fall back to the same
42
+ // midnight-based behavior instead.
43
+ function defaultCooldownDurationMs(): number {
44
+ return computeQuotaCooldownMs(Date.now());
45
+ }
46
+
47
+ export function markAccountRateLimited(
48
+ accountId: string,
49
+ cooldownMs?: number,
50
+ reason?: string,
51
+ options: { silent?: boolean } = {},
52
+ ): void {
53
+ const duration = cooldownMs ?? defaultCooldownDurationMs();
54
+ const until = Date.now() + duration;
55
+ const cooldownReason = reason ?? "RateLimited";
56
+
57
+ cooldowns.set(accountId, {
58
+ until,
59
+ reason: cooldownReason,
60
+ });
61
+
62
+ // Persist to database
63
+ if (accountId !== "global") {
64
+ try {
65
+ updateAccountCooldown(accountId, until, cooldownReason);
66
+ } catch (err) {
67
+ console.error(
68
+ `❌ [AccountManager] Failed to save cooldown to DB for ${accountId}:`,
69
+ (err as Error).message,
70
+ );
71
+ }
72
+ }
73
+
74
+ if (!options.silent) {
75
+ console.log(
76
+ `⏱️ [AccountManager] Cooldown set | ${accountId} | reason=${cooldownReason} | ${Math.round(duration / 1000)}s | until=${formatCooldownUntil(new Date(until))}`,
77
+ );
78
+ }
79
+ }
80
+
81
+ export function clearAccountCooldown(accountId: string): void {
82
+ cooldowns.delete(accountId);
83
+ if (accountId !== "global") {
84
+ try {
85
+ updateAccountCooldown(accountId, 0, null);
86
+ } catch (err) {
87
+ console.error(
88
+ `❌ [AccountManager] Failed to clear cooldown in DB for ${accountId}:`,
89
+ (err as Error).message,
90
+ );
91
+ }
92
+ }
93
+ }
94
+
95
+ export function clearAllAccountCooldowns(): number {
96
+ const accounts = loadAccounts();
97
+ let count = 0;
98
+ for (const account of accounts) {
99
+ if (cooldowns.has(account.id) || (account.cooldown_until && account.cooldown_until > 0)) {
100
+ clearAccountCooldown(account.id);
101
+ count++;
102
+ }
103
+ }
104
+ cooldowns.delete("global");
105
+ return count;
106
+ }
107
+
108
+ export function getAccountCooldownInfo(
109
+ accountId: string,
110
+ ): { onCooldown: boolean; remainingMs: number; reason: string } | null {
111
+ const entry = cooldowns.get(accountId);
112
+ if (!entry) return null;
113
+ const remaining = entry.until - Date.now();
114
+ if (remaining <= 0) {
115
+ cooldowns.delete(accountId);
116
+ if (accountId !== "global") {
117
+ try {
118
+ updateAccountCooldown(accountId, 0, null);
119
+ } catch (err) {
120
+ console.error(
121
+ `❌ [AccountManager] Failed to clear expired cooldown in DB:`,
122
+ (err as Error).message,
123
+ );
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+ return { onCooldown: true, remainingMs: remaining, reason: entry.reason };
129
+ }
130
+
131
+ function isAccountOnCooldown(accountId: string): boolean {
132
+ return getAccountCooldownInfo(accountId) !== null;
133
+ }
134
+
135
+ // ─── Headers-ready gate (mirrors upstream `markAccountReady`) ───────────────
136
+ // Accounts whose anti-bot headers were successfully captured are "ready". The
137
+ // rotation pickers below skip not-ready accounts whenever at least one account
138
+ // IS ready, so a request never lands on a lane that is still warming up or
139
+ // whose context just died (Playwright page unavailable → 300s init cooldown).
140
+ // The gate degrades to "all accounts pass" when NO account is ready (startup
141
+ // warmup / freshly-restored headers) so a single-account or cold pool stays
142
+ // lossless — exactly the upstream `anyReady` rule.
143
+ const headersReadyAccounts = new Set<string>();
144
+
145
+ export function markAccountHeadersReady(accountId: string): void {
146
+ if (!accountId || accountId === "global") return;
147
+ headersReadyAccounts.add(accountId);
148
+ }
149
+
150
+ export function unmarkAccountHeadersReady(accountId: string): void {
151
+ if (!accountId) return;
152
+ headersReadyAccounts.delete(accountId);
153
+ }
154
+
155
+ export function isAccountHeadersReady(accountId: string): boolean {
156
+ return headersReadyAccounts.has(accountId);
157
+ }
158
+
159
+ function anyUsableAccountHeadersReady(
160
+ accounts: QwenAccount[],
161
+ triedSet?: Set<string>,
162
+ ): boolean {
163
+ return accounts.some(
164
+ (a) =>
165
+ (!triedSet || !triedSet.has(a.id)) &&
166
+ !isAccountOnCooldown(a.id) &&
167
+ isAccountHeadersReady(a.id),
168
+ );
169
+ }
170
+
171
+ function passesHeadersReadyGate(
172
+ accountId: string,
173
+ anyReady: boolean,
174
+ ): boolean {
175
+ return !anyReady || isAccountHeadersReady(accountId);
176
+ }
177
+
178
+ export function syncCooldownsFromDb(accounts: QwenAccount[]): void {
179
+ const now = Date.now();
180
+ for (const account of accounts) {
181
+ if (account.cooldown_until && account.cooldown_until > now) {
182
+ if (!cooldowns.has(account.id)) {
183
+ cooldowns.set(account.id, {
184
+ until: account.cooldown_until,
185
+ reason: account.cooldown_reason || "RateLimited",
186
+ });
187
+ }
188
+ } else {
189
+ if (cooldowns.has(account.id)) {
190
+ cooldowns.delete(account.id);
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ export function getNextAccount(): QwenAccount | null {
197
+ const accounts = loadAccounts();
198
+ if (accounts.length === 0) {
199
+ return null;
200
+ }
201
+
202
+ syncCooldownsFromDb(accounts);
203
+
204
+ // Ordena por prioridade (contas que funcionaram bem vêm primeiro)
205
+ const prioritized = getAccountsByPriority(accounts);
206
+ // Gate: once ANY usable account has captured headers, only ready accounts rotate.
207
+ // If all ready accounts are on cooldown, anyReady degrades to false so non-ready
208
+ // accounts can be initialized on-demand instead of falsely reporting pool exhaustion.
209
+ const anyReady = anyUsableAccountHeadersReady(accounts);
210
+
211
+ for (let i = 0; i < prioritized.length; i++) {
212
+ const account = prioritized[currentIndex % prioritized.length];
213
+ currentIndex = (currentIndex + 1) % prioritized.length;
214
+ if (
215
+ !isAccountOnCooldown(account.id) &&
216
+ passesHeadersReadyGate(account.id, anyReady)
217
+ ) {
218
+ return account;
219
+ }
220
+ }
221
+
222
+ // All accounts on cooldown — return the one with the shortest remaining cooldown.
223
+ let best: QwenAccount | null = null;
224
+ let bestRemaining = Infinity;
225
+ for (const account of prioritized) {
226
+ const info = getAccountCooldownInfo(account.id);
227
+ if (info && info.remainingMs < bestRemaining) {
228
+ bestRemaining = info.remainingMs;
229
+ best = account;
230
+ }
231
+ }
232
+ return best;
233
+ }
234
+
235
+ export function getNextAvailableAccount(
236
+ triedAccountIds?: Set<string> | string,
237
+ ): QwenAccount | null {
238
+ const accounts = loadAccounts();
239
+ if (accounts.length === 0) return null;
240
+
241
+ syncCooldownsFromDb(accounts);
242
+
243
+ let triedSet: Set<string>;
244
+ if (triedAccountIds instanceof Set) {
245
+ triedSet = triedAccountIds;
246
+ } else {
247
+ triedSet = new Set(triedAccountIds ? [triedAccountIds] : []);
248
+ }
249
+
250
+ // Ordena por prioridade (contas que funcionaram bem vêm primeiro)
251
+ const prioritized = getAccountsByPriority(accounts);
252
+ // Gate: once ANY untried, non-cooldown account has captured headers, only ready accounts rotate.
253
+ // If all ready accounts are on cooldown or tried, anyReady degrades to false so non-ready
254
+ // accounts can be initialized on-demand instead of falsely reporting pool exhaustion.
255
+ const anyReady = anyUsableAccountHeadersReady(accounts, triedSet);
256
+
257
+ // 1. Try to find an untried account that is NOT on cooldown
258
+ for (let i = 0; i < prioritized.length; i++) {
259
+ const idx = (currentIndex + i) % prioritized.length;
260
+ const account = prioritized[idx];
261
+ if (triedSet.has(account.id)) continue;
262
+ if (
263
+ !isAccountOnCooldown(account.id) &&
264
+ passesHeadersReadyGate(account.id, anyReady)
265
+ ) {
266
+ currentIndex = (idx + 1) % prioritized.length;
267
+ return account;
268
+ }
269
+ }
270
+
271
+ // 2. If all untried accounts are on cooldown, return the untried one with the shortest remaining cooldown
272
+ let best: QwenAccount | null = null;
273
+ let bestRemaining = Infinity;
274
+ for (const account of prioritized) {
275
+ if (triedSet.has(account.id)) continue;
276
+ const info = getAccountCooldownInfo(account.id);
277
+ if (info && info.remainingMs < bestRemaining) {
278
+ bestRemaining = info.remainingMs;
279
+ best = account;
280
+ }
281
+ }
282
+ return best;
283
+ }
284
+
285
+ export function getCooldownStatus(): Record<
286
+ string,
287
+ { remainingMs: number; reason: string }
288
+ > {
289
+ const result: Record<string, { remainingMs: number; reason: string }> = {};
290
+ for (const [id, info] of cooldowns.entries()) {
291
+ const remaining = info.until - Date.now();
292
+ if (remaining > 0) {
293
+ result[id] = { remainingMs: remaining, reason: info.reason };
294
+ }
295
+ }
296
+ return result;
297
+ }
@@ -0,0 +1,163 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ } from "fs";
7
+ import { join, resolve } from "path";
8
+
9
+ interface PriorityData {
10
+ accountOrder: string[];
11
+ lastUpdated: number;
12
+ }
13
+
14
+ /**
15
+ * Mock account used by test suites (TEST_MOCK_QWEN_AUTH). Must never be
16
+ * persisted to the real priority file or it pollutes production routing.
17
+ */
18
+ const MOCK_ACCOUNT_ID = "mock-account";
19
+
20
+ function isPersistableAccount(accountId: string): boolean {
21
+ return accountId !== MOCK_ACCOUNT_ID;
22
+ }
23
+
24
+ import { getDataDir, getAccountPriorityPath } from "./paths.ts";
25
+
26
+ const DATA_DIR = getDataDir();
27
+ const PRIORITY_FILE = getAccountPriorityPath();
28
+
29
+ let priorityCache: PriorityData | null = null;
30
+
31
+ /** Invalidate the in-memory priority cache (test isolation). */
32
+ export function invalidatePriorityCache(): void {
33
+ priorityCache = null;
34
+ }
35
+
36
+ function loadPriority(): PriorityData {
37
+ if (priorityCache) return priorityCache;
38
+
39
+ try {
40
+ if (existsSync(PRIORITY_FILE)) {
41
+ const data = JSON.parse(readFileSync(PRIORITY_FILE, "utf-8"));
42
+ // Filter out any mock/test accounts that may have leaked into the file
43
+ const accountOrder: string[] = (data.accountOrder || []).filter(
44
+ isPersistableAccount,
45
+ );
46
+ priorityCache = {
47
+ accountOrder,
48
+ lastUpdated: data.lastUpdated || 0,
49
+ };
50
+ return priorityCache!;
51
+ }
52
+ } catch (err) {
53
+ console.error("❌ [AccountPriority] Failed to load priority file:", (err as Error).message);
54
+ }
55
+
56
+ priorityCache = { accountOrder: [], lastUpdated: 0 };
57
+ return priorityCache;
58
+ }
59
+
60
+ function savePriority(data: PriorityData): void {
61
+ try {
62
+ mkdirSync(DATA_DIR, { recursive: true });
63
+ writeFileSync(PRIORITY_FILE, JSON.stringify(data, null, 2), "utf-8");
64
+ priorityCache = data;
65
+ } catch (err) {
66
+ console.error("❌ [AccountPriority] Failed to save priority file:", (err as Error).message);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Reordena contas: conta que funcionou vai para o topo
72
+ */
73
+ export function markAccountSuccessful(accountId: string): void {
74
+ if (!isPersistableAccount(accountId)) return;
75
+ const data = loadPriority();
76
+
77
+ // Remove se já existe
78
+ data.accountOrder = data.accountOrder.filter(id => id !== accountId);
79
+
80
+ // Adiciona no topo
81
+ data.accountOrder.unshift(accountId);
82
+ data.lastUpdated = Date.now();
83
+
84
+ savePriority(data);
85
+ }
86
+
87
+ /**
88
+ * Reordena contas: conta que falhou vai para o final
89
+ */
90
+ export function markAccountFailed(accountId: string): void {
91
+ if (!isPersistableAccount(accountId)) return;
92
+ const data = loadPriority();
93
+
94
+ // Remove se já existe
95
+ data.accountOrder = data.accountOrder.filter(id => id !== accountId);
96
+
97
+ // Adiciona no final
98
+ data.accountOrder.push(accountId);
99
+ data.lastUpdated = Date.now();
100
+
101
+ savePriority(data);
102
+ }
103
+
104
+ /**
105
+ * Adiciona conta à prioridade se não existir (prioridade inicial).
106
+ * Contas novas entram no final da lista, mantendo a ordem de configuração.
107
+ */
108
+ export function ensureAccountInPriority(accountId: string): void {
109
+ if (!isPersistableAccount(accountId)) return;
110
+ const data = loadPriority();
111
+
112
+ // Se já existe, não faz nada
113
+ if (data.accountOrder.includes(accountId)) {
114
+ return;
115
+ }
116
+
117
+ // Adiciona no final (prioridade inicial baixa)
118
+ data.accountOrder.push(accountId);
119
+ data.lastUpdated = Date.now();
120
+
121
+ savePriority(data);
122
+ }
123
+
124
+ /**
125
+ * Retorna contas ordenadas por prioridade (melhores primeiro)
126
+ */
127
+ export function getAccountsByPriority<T extends { id: string }>(accounts: T[]): T[] {
128
+ const data = loadPriority();
129
+
130
+ if (data.accountOrder.length === 0) {
131
+ return accounts;
132
+ }
133
+
134
+ // Cria mapa de prioridade (menor índice = maior prioridade)
135
+ const priorityMap = new Map<string, number>();
136
+ data.accountOrder.forEach((id, index) => {
137
+ priorityMap.set(id, index);
138
+ });
139
+
140
+ // Ordena: contas na lista de prioridade vêm primeiro, depois as que não estão na lista
141
+ return [...accounts].sort((a, b) => {
142
+ const priorityA = priorityMap.get(a.id);
143
+ const priorityB = priorityMap.get(b.id);
144
+
145
+ // Ambos têm prioridade definida
146
+ if (priorityA !== undefined && priorityB !== undefined) {
147
+ return priorityA - priorityB;
148
+ }
149
+
150
+ // Apenas A tem prioridade
151
+ if (priorityA !== undefined) {
152
+ return -1;
153
+ }
154
+
155
+ // Apenas B tem prioridade
156
+ if (priorityB !== undefined) {
157
+ return 1;
158
+ }
159
+
160
+ // Nenhum tem prioridade, mantém ordem original
161
+ return 0;
162
+ });
163
+ }
@@ -0,0 +1,186 @@
1
+ import "dotenv/config";
2
+ import crypto from "crypto";
3
+ import { getDatabase } from "./database.ts";
4
+ import { decrypt, encrypt } from "./crypto-utils.ts";
5
+
6
+ export interface QwenAccount {
7
+ id: string;
8
+ email: string;
9
+ password: string;
10
+ cooldown_until?: number;
11
+ cooldown_reason?: string | null;
12
+ }
13
+
14
+ function generateId(email: string): string {
15
+ return crypto
16
+ .createHash("md5")
17
+ .update(email)
18
+ .digest("hex")
19
+ .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5");
20
+ }
21
+
22
+ function parseEnvAccounts(): QwenAccount[] {
23
+ const envAccounts = process.env.QWEN_ACCOUNTS;
24
+ if (!envAccounts) return [];
25
+
26
+ const separator = envAccounts.includes(";") ? ";" : ",";
27
+
28
+ return envAccounts
29
+ .split(separator)
30
+ .map((entry, index) => {
31
+ const trimmed = entry.trim();
32
+ const colonIdx = trimmed.indexOf(":");
33
+ if (colonIdx === -1) {
34
+ console.warn(
35
+ `[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
36
+ );
37
+ return null;
38
+ }
39
+ const email = trimmed.substring(0, colonIdx);
40
+ const password = trimmed.substring(colonIdx + 1);
41
+ if (!email || !password) {
42
+ console.warn(
43
+ `[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
44
+ );
45
+ return null;
46
+ }
47
+ return {
48
+ id: generateId(email),
49
+ email: email.trim(),
50
+ password: password.trim(),
51
+ };
52
+ })
53
+ .filter((a): a is QwenAccount => a !== null);
54
+ }
55
+
56
+ let lastSyncedEnv = "";
57
+ let lastSyncTime = 0;
58
+ const SYNC_INTERVAL = 30_000;
59
+
60
+ function syncEnvAccounts(): void {
61
+ const envAccounts = process.env.QWEN_ACCOUNTS || "";
62
+ const now = Date.now();
63
+ if (envAccounts === lastSyncedEnv && now - lastSyncTime < SYNC_INTERVAL)
64
+ return;
65
+
66
+ lastSyncedEnv = envAccounts;
67
+ lastSyncTime = now;
68
+
69
+ const accounts = parseEnvAccounts();
70
+ if (accounts.length === 0) return;
71
+
72
+ const db = getDatabase();
73
+ const upsert = db.prepare(`
74
+ INSERT INTO accounts (id, email, password) VALUES (?, ?, ?)
75
+ ON CONFLICT(email) DO UPDATE SET password = excluded.password, updated_at = datetime('now')
76
+ `);
77
+
78
+ const sync = db.transaction(() => {
79
+ for (const acc of accounts) {
80
+ upsert.run(acc.id, acc.email, encrypt(acc.password));
81
+ }
82
+ });
83
+
84
+ sync();
85
+ }
86
+
87
+ let accountsCache: QwenAccount[] | null = null;
88
+ let accountsCacheTime = 0;
89
+ const ACCOUNTS_CACHE_TTL = 5_000;
90
+
91
+ function getCachedAccounts(): QwenAccount[] {
92
+ syncEnvAccounts();
93
+
94
+ const now = Date.now();
95
+ if (accountsCache && now - accountsCacheTime < ACCOUNTS_CACHE_TTL) {
96
+ return accountsCache;
97
+ }
98
+
99
+ const db = getDatabase();
100
+ const rows = db
101
+ .prepare(
102
+ "SELECT id, email, password, cooldown_until, cooldown_reason FROM accounts ORDER BY created_at ASC",
103
+ )
104
+ .all() as QwenAccount[];
105
+
106
+ accountsCache = rows.map((row) => ({
107
+ ...row,
108
+ password: decrypt(row.password),
109
+ }));
110
+ accountsCacheTime = now;
111
+ return accountsCache;
112
+ }
113
+
114
+ export function loadAccounts(): QwenAccount[] {
115
+ return getCachedAccounts().map((account) => ({
116
+ ...account,
117
+ password: "***",
118
+ }));
119
+ }
120
+
121
+ export function invalidateAccountsCache(): void {
122
+ accountsCache = null;
123
+ accountsCacheTime = 0;
124
+ }
125
+
126
+ export function addAccount(
127
+ email: string,
128
+ password: string,
129
+ id?: string,
130
+ ): QwenAccount {
131
+ if (!email || typeof email !== "string" || email.trim().length === 0) {
132
+ throw new Error("Email is required");
133
+ }
134
+
135
+ const db = getDatabase();
136
+
137
+ const existing = db
138
+ .prepare("SELECT id FROM accounts WHERE email = ?")
139
+ .get(email.trim());
140
+ if (existing) {
141
+ throw new Error("Account with this email already exists");
142
+ }
143
+
144
+ const newAccount: QwenAccount = {
145
+ id: id || crypto.randomUUID(),
146
+ email: email.trim(),
147
+ password,
148
+ };
149
+
150
+ db.prepare("INSERT INTO accounts (id, email, password) VALUES (?, ?, ?)").run(
151
+ newAccount.id,
152
+ newAccount.email,
153
+ encrypt(newAccount.password),
154
+ );
155
+
156
+ invalidateAccountsCache();
157
+ return newAccount;
158
+ }
159
+
160
+ export function removeAccount(id: string): boolean {
161
+ const db = getDatabase();
162
+ const result = db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
163
+ invalidateAccountsCache();
164
+ return result.changes > 0;
165
+ }
166
+
167
+ export function listAccounts(): QwenAccount[] {
168
+ return loadAccounts();
169
+ }
170
+
171
+ export function getAccountCredentials(id: string): QwenAccount | undefined {
172
+ const cached = getCachedAccounts();
173
+ return cached.find((a) => a.id === id);
174
+ }
175
+
176
+ export function updateAccountCooldown(
177
+ id: string,
178
+ cooldownUntil: number,
179
+ reason: string | null,
180
+ ): void {
181
+ const db = getDatabase();
182
+ db.prepare(
183
+ "UPDATE accounts SET cooldown_until = ?, cooldown_reason = ? WHERE id = ?",
184
+ ).run(cooldownUntil, reason, id);
185
+ invalidateAccountsCache();
186
+ }