qwenproxy-cli 1.2.9 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.2.9",
3
+ "version": "1.3.0",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -26,8 +26,8 @@
26
26
  "reset": "npx tsx src/reset-cooldowns.ts",
27
27
  "purge": "npx tsx src/delete-chats.ts",
28
28
  "test": "npm run test:mock && npm run test:live",
29
- "test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
30
- "test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
29
+ "test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts src/tests/auth-jwt-and-validation.test.ts src/tests/tui-port-and-key-modal.test.ts",
30
+ "test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts src/tests/auth-jwt-and-validation.test.ts src/tests/tui-port-and-key-modal.test.ts",
31
31
  "test:live": "tsx --test src/tests/concurrentChat.test.ts src/tests/agenticStress.test.ts",
32
32
  "benchmark:proxy": "npx tsx src/benchmarks/proxy-baseline.ts",
33
33
  "typecheck": "tsc --noEmit"
package/src/api/server.ts CHANGED
@@ -172,7 +172,13 @@ app.use("*", async (c, next) => {
172
172
  c.req.path === "/logs" ||
173
173
  c.req.path.startsWith("/logs") ||
174
174
  c.req.path.startsWith("/diagnostics") ||
175
- c.req.path === "/favicon.ico";
175
+ c.req.path === "/favicon.ico" ||
176
+ c.req.path === "/v1/models" ||
177
+ c.req.path.startsWith("/v1/models/") ||
178
+ c.req.path === "/models" ||
179
+ c.req.path.startsWith("/models/") ||
180
+ c.req.path === "/v1/chat/mode" ||
181
+ c.req.path === "/chat/mode";
176
182
 
177
183
  if (!isProbe) {
178
184
  metrics.increment("requests.total");
@@ -445,7 +451,13 @@ app.onError((err, c) => {
445
451
  c.req.path === "/health" ||
446
452
  c.req.path === "/metrics" ||
447
453
  c.req.path.startsWith("/diagnostics") ||
448
- c.req.path === "/favicon.ico";
454
+ c.req.path === "/favicon.ico" ||
455
+ c.req.path === "/v1/models" ||
456
+ c.req.path.startsWith("/v1/models/") ||
457
+ c.req.path === "/models" ||
458
+ c.req.path.startsWith("/models/") ||
459
+ c.req.path === "/v1/chat/mode" ||
460
+ c.req.path === "/chat/mode";
449
461
  if (!isProbe) {
450
462
  metrics.increment("requests.errors");
451
463
  }
@@ -4,7 +4,8 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { getDatabase } from "./database.ts";
6
6
  import { decrypt, encrypt } from "./crypto-utils.ts";
7
- import { getAccountProfilePath, getProfilesDir } from "./paths.ts";
7
+ import { getAccountProfilePath, getProfilesDir, isRunningUnderNodeTest } from "./paths.ts";
8
+ import { updateEnvVariable } from "./local-auth.ts";
8
9
 
9
10
  export interface QwenAccount {
10
11
  id: string;
@@ -14,6 +15,29 @@ export interface QwenAccount {
14
15
  cooldown_reason?: string | null;
15
16
  }
16
17
 
18
+ export function isPlaceholderAccountEmail(email: string, password?: string): boolean {
19
+ if (!email || typeof email !== "string") return false;
20
+ const clean = email.trim().toLowerCase();
21
+ if (
22
+ clean === "email1@example.com" ||
23
+ clean === "email2@example.com" ||
24
+ clean === "user@example.com" ||
25
+ clean === "test@example.com" ||
26
+ clean === "your-email@example.com" ||
27
+ clean.startsWith("email1@") ||
28
+ clean.startsWith("email2@")
29
+ ) {
30
+ return true;
31
+ }
32
+ if (
33
+ password &&
34
+ (password === "password1" || password === "password2" || password === "changeme")
35
+ ) {
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+
17
41
  function generateId(email: string): string {
18
42
  return crypto
19
43
  .createHash("md5")
@@ -59,10 +83,12 @@ function parseEnvAccounts(): QwenAccount[] {
59
83
  }
60
84
  const email = clean(trimmed.substring(0, colonIdx));
61
85
  const password = clean(trimmed.substring(colonIdx + 1));
62
- if (!email || !password) {
63
- console.warn(
64
- `[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
65
- );
86
+ if (!email || !password || isPlaceholderAccountEmail(email, password)) {
87
+ if (!email || !password) {
88
+ console.warn(
89
+ `[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
90
+ );
91
+ }
66
92
  return null;
67
93
  }
68
94
  return {
@@ -79,6 +105,17 @@ let lastSyncTime = 0;
79
105
  const SYNC_INTERVAL = 30_000;
80
106
 
81
107
  function syncEnvAccounts(): void {
108
+ const db = getDatabase();
109
+ // Automatically purge any dummy placeholder accounts that may have been seeded from .env.example
110
+ try {
111
+ db.prepare(`
112
+ DELETE FROM accounts
113
+ WHERE lower(email) IN ('email1@example.com', 'email2@example.com', 'user@example.com', 'test@example.com', 'your-email@example.com')
114
+ OR lower(email) LIKE 'email1@%'
115
+ OR lower(email) LIKE 'email2@%'
116
+ `).run();
117
+ } catch {}
118
+
82
119
  const envAccounts = process.env.QWEN_ACCOUNTS || "";
83
120
  const now = Date.now();
84
121
  if (envAccounts === lastSyncedEnv && now - lastSyncTime < SYNC_INTERVAL)
@@ -90,7 +127,6 @@ function syncEnvAccounts(): void {
90
127
  const accounts = parseEnvAccounts();
91
128
  if (accounts.length === 0) return;
92
129
 
93
- const db = getDatabase();
94
130
  const upsert = db.prepare(`
95
131
  INSERT INTO accounts (id, email, password) VALUES (?, ?, ?)
96
132
  ON CONFLICT(email) DO UPDATE SET password = excluded.password, updated_at = datetime('now')
@@ -124,15 +160,17 @@ function getCachedAccounts(): QwenAccount[] {
124
160
  )
125
161
  .all() as QwenAccount[];
126
162
 
127
- accountsCache = rows.map((row) => {
128
- let password = "";
129
- try {
130
- password = decrypt(row.password);
131
- } catch {
132
- password = "";
133
- }
134
- return { ...row, password };
135
- });
163
+ accountsCache = rows
164
+ .filter((row) => !isPlaceholderAccountEmail(row.email))
165
+ .map((row) => {
166
+ let password = "";
167
+ try {
168
+ password = decrypt(row.password);
169
+ } catch {
170
+ password = "";
171
+ }
172
+ return { ...row, password };
173
+ });
136
174
  accountsCacheTime = now;
137
175
  return accountsCache;
138
176
  }
@@ -316,22 +354,85 @@ export function addAccount(
316
354
  return newAccount;
317
355
  }
318
356
 
319
- function wipeAccountSessionFiles(id: string): void {
357
+ export function wipeAccountSessionFiles(id: string): void {
320
358
  const profilePath = getAccountProfilePath(id);
321
359
  try {
322
360
  fs.rmSync(profilePath, { recursive: true, force: true });
323
- } catch {}
361
+ } catch (error) {
362
+ const message =
363
+ error instanceof Error ? error.message : String(error ?? "");
364
+ if (
365
+ message.includes("EPERM") ||
366
+ message.includes("EBUSY") ||
367
+ message.includes("Permission denied")
368
+ ) {
369
+ try {
370
+ const stalePath = `${profilePath}.stale-${Date.now()}`;
371
+ fs.renameSync(profilePath, stalePath);
372
+ } catch {}
373
+ }
374
+ }
324
375
  const siblingState = path.join(getProfilesDir(), `${id}_state.json`);
325
376
  try {
326
377
  fs.rmSync(siblingState, { force: true });
327
378
  } catch {}
328
379
  }
329
380
 
381
+ export function removeAccountFromEnv(emailToRemove: string): void {
382
+ const target = emailToRemove.trim().toLowerCase();
383
+ const rawEnv = process.env.QWEN_ACCOUNTS;
384
+ if (!rawEnv) return;
385
+
386
+ const clean = (s: string) => s.trim().replace(/^[,;\s"'`]+|[,;\s"'`]+$/g, "").trim();
387
+ const lines = rawEnv.split(/[\r\n;]+/);
388
+ const remaining: string[] = [];
389
+
390
+ for (const rawLine of lines) {
391
+ const line = clean(rawLine);
392
+ if (!line) continue;
393
+
394
+ if (line.includes(",") && (line.match(/@/g) || []).length > 1) {
395
+ for (const seg of line.split(",")) {
396
+ const trimmed = clean(seg);
397
+ if (!trimmed) continue;
398
+ const colonIdx = trimmed.indexOf(":");
399
+ const email = clean(colonIdx !== -1 ? trimmed.substring(0, colonIdx) : trimmed);
400
+ if (email.toLowerCase() !== target && !isPlaceholderAccountEmail(email)) {
401
+ remaining.push(trimmed);
402
+ }
403
+ }
404
+ } else {
405
+ const colonIdx = line.indexOf(":");
406
+ const email = clean(colonIdx !== -1 ? line.substring(0, colonIdx) : line);
407
+ if (email.toLowerCase() !== target && !isPlaceholderAccountEmail(email)) {
408
+ remaining.push(line);
409
+ }
410
+ }
411
+ }
412
+
413
+ const newEnvValue = remaining.join(",");
414
+ process.env.QWEN_ACCOUNTS = newEnvValue;
415
+ lastSyncedEnv = newEnvValue;
416
+ lastSyncTime = Date.now();
417
+
418
+ if (!isRunningUnderNodeTest()) {
419
+ try {
420
+ updateEnvVariable("QWEN_ACCOUNTS", newEnvValue);
421
+ } catch {}
422
+ }
423
+ }
424
+
330
425
  export function removeAccount(id: string): boolean {
331
426
  const db = getDatabase();
427
+ const account = db
428
+ .prepare("SELECT email FROM accounts WHERE id = ?")
429
+ .get(id) as { email: string } | undefined;
332
430
  const result = db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
333
431
  if (result.changes > 0) {
334
432
  wipeAccountSessionFiles(id);
433
+ if (account?.email) {
434
+ removeAccountFromEnv(account.email);
435
+ }
335
436
  }
336
437
  invalidateAccountsCache();
337
438
  return result.changes > 0;
@@ -43,6 +43,58 @@ export function assertBindAllowed(host: string, apiKey: string): void {
43
43
  );
44
44
  }
45
45
 
46
+ export function updateEnvVariable(
47
+ key: string,
48
+ value: string,
49
+ envPath = getEnvFilePath(),
50
+ ): void {
51
+ try {
52
+ const existing = fs.existsSync(envPath)
53
+ ? fs.readFileSync(envPath, "utf-8")
54
+ : "";
55
+ const regex = new RegExp(`^([ \\t]*)${key}[ \\t]*=.*$`, "m");
56
+ const assignment = regex.exec(existing);
57
+
58
+ let updated: string;
59
+ if (assignment) {
60
+ const start = assignment.index;
61
+ const end = start + assignment[0].length;
62
+ updated =
63
+ existing.slice(0, start) +
64
+ `${assignment[1]}${key}=${value}` +
65
+ existing.slice(end);
66
+ } else {
67
+ const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
68
+ const prefix =
69
+ existing.length === 0 || existing.endsWith("\n") ? "" : lineEnding;
70
+ updated = `${existing}${prefix}${key}=${value}${lineEnding}`;
71
+ }
72
+
73
+ fs.writeFileSync(envPath, updated, { encoding: "utf-8", mode: 0o600 });
74
+ } catch (err) {
75
+ console.warn(
76
+ `⚠️ [Config] Could not update ${key} in .env:`,
77
+ err instanceof Error ? err.message : String(err),
78
+ );
79
+ }
80
+ }
81
+
82
+ export function persistServerPort(
83
+ port: number,
84
+ envPath = getEnvFilePath(),
85
+ ): void {
86
+ updateEnvVariable("PORT", String(port), envPath);
87
+ process.env.PORT = String(port);
88
+ }
89
+
90
+ export function persistCustomApiKey(
91
+ apiKey: string,
92
+ envPath = getEnvFilePath(),
93
+ ): void {
94
+ updateEnvVariable("API_KEY", apiKey.trim(), envPath);
95
+ process.env.API_KEY = apiKey.trim();
96
+ }
97
+
46
98
  export function persistApiKey(
47
99
  apiKey: string,
48
100
  envPath = getEnvFilePath(),
package/src/core/paths.ts CHANGED
@@ -181,6 +181,9 @@ export function getTuiSettingsPath(customDataDir?: string): string {
181
181
  * Path to user configuration .env file (either local or in global data dir).
182
182
  */
183
183
  export function getEnvFilePath(customDataDir?: string): string {
184
+ if (isRunningUnderNodeTest()) {
185
+ return path.join(customDataDir || getDataDir(), ".env");
186
+ }
184
187
  const localEnv = path.resolve(".env");
185
188
  if (fs.existsSync(localEnv)) {
186
189
  return localEnv;
package/src/login.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from "crypto";
1
2
  import {
2
3
  addAccount,
3
4
  removeAccount,
@@ -76,30 +77,76 @@ async function addAccountFlow() {
76
77
  clear();
77
78
  console.log("=== Add New Account ===\n");
78
79
  const email = await askQuestion("Email: ");
79
- if (!email) {
80
+ if (!email || !email.trim()) {
80
81
  console.log("Email is required.");
81
82
  await askQuestion("Press Enter to continue...");
82
83
  return;
83
84
  }
84
85
 
85
86
  const password = await askQuestion("Password: ");
86
- if (!password) {
87
+ if (!password || !password.trim()) {
87
88
  console.log("Password is required.");
88
89
  await askQuestion("Press Enter to continue...");
89
90
  return;
90
91
  }
91
92
 
93
+ const trimmedEmail = email.trim();
94
+ const trimmedPassword = password.trim();
95
+
96
+ const existingAccounts = listAccounts();
97
+ if (
98
+ existingAccounts.some(
99
+ (a) => a.email.toLowerCase() === trimmedEmail.toLowerCase(),
100
+ )
101
+ ) {
102
+ console.log(
103
+ `\n❌ Error: An account with email "${trimmedEmail}" already exists.`,
104
+ );
105
+ await askQuestion("Press Enter to continue...");
106
+ return;
107
+ }
108
+
109
+ const tempId = crypto.randomUUID();
110
+ console.log(
111
+ `\n⏳ Validating credentials for ${maskEmail(trimmedEmail)} with Qwen via Playwright...`,
112
+ );
113
+
92
114
  let account: QwenAccount | null = null;
93
115
  try {
94
- account = addAccount(email, password);
95
- console.log(`Account added: ${maskEmail(account.email)} (${account.id})`);
96
- console.log("Credentials will be validated by Playwright on server start.");
116
+ const { validateAccountLogin } = await import("./services/playwright.ts");
117
+ const { wipeAccountSessionFiles } = await import("./core/accounts.ts");
118
+ const { clearAccountCooldown } = await import("./core/account-manager.ts");
119
+ const { config } = await import("./core/config.ts");
120
+
121
+ const ok = await validateAccountLogin(
122
+ { id: tempId, email: trimmedEmail, password: trimmedPassword },
123
+ config.playwright.headless,
124
+ config.playwright.browser,
125
+ );
126
+
127
+ if (ok) {
128
+ account = addAccount(trimmedEmail, trimmedPassword, tempId);
129
+ console.log(
130
+ `\n✅ Account verified and saved: ${maskEmail(account.email)} (${account.id})`,
131
+ );
132
+ } else {
133
+ wipeAccountSessionFiles(tempId);
134
+ clearAccountCooldown(tempId);
135
+ console.log(
136
+ `\n❌ Authentication failed: Invalid credentials or unhandled login challenge.`,
137
+ );
138
+ console.log("The account was NOT saved.");
139
+ }
97
140
  } catch (err: any) {
98
- if (account) removeAccount(account.id);
99
- console.log(`\nError: ${err.message}`);
141
+ const { wipeAccountSessionFiles } = await import("./core/accounts.ts");
142
+ const { clearAccountCooldown } = await import("./core/account-manager.ts");
143
+ wipeAccountSessionFiles(tempId);
144
+ clearAccountCooldown(tempId);
145
+ console.log(`\n❌ Validation error: ${err?.message || String(err)}`);
146
+ console.log("The account was NOT saved.");
100
147
  }
101
148
 
102
- await askQuestion("Press Enter to continue...");
149
+ await askQuestion("\nPress Enter to continue...");
103
150
  }
104
151
 
105
152
  async function removeAccountFlow() {
@@ -341,21 +341,22 @@ async function getSTSToken(
341
341
  filesize: number,
342
342
  filetype: string,
343
343
  headers: Record<string, string>,
344
+ accountId?: string,
344
345
  ): Promise<STSResponse["data"]> {
345
- const response = await fetch(
346
- qwenUrl("/api/v2/files/getstsToken"),
347
- {
346
+ const doFetch = (reqHeaders: Record<string, string>) =>
347
+ fetch(qwenUrl("/api/v2/files/getstsToken"), {
348
348
  method: "POST",
349
349
  headers: buildQwenRequestHeaders({
350
- cookie: headers.cookie,
351
- userAgent: headers["user-agent"],
352
- bxUa: headers["bx-ua"],
353
- bxUmidtoken: headers["bx-umidtoken"],
354
- bxV: headers["bx-v"],
350
+ cookie: reqHeaders.cookie,
351
+ userAgent: reqHeaders["user-agent"],
352
+ bxUa: reqHeaders["bx-ua"],
353
+ bxUmidtoken: reqHeaders["bx-umidtoken"],
354
+ bxV: reqHeaders["bx-v"],
355
355
  }),
356
356
  body: JSON.stringify({ filename, filesize: String(filesize), filetype }),
357
- },
358
- );
357
+ });
358
+
359
+ let response = await doFetch(headers);
359
360
 
360
361
  if (!response.ok) {
361
362
  const errorText = await response.text().catch(() => "");
@@ -364,8 +365,43 @@ async function getSTSToken(
364
365
  );
365
366
  }
366
367
 
367
- const data = await response.json();
368
- if (!data.success || !data.data) {
368
+ let data = await response.json().catch(() => null);
369
+ const is401 =
370
+ data?.success === false &&
371
+ (data?.data?.code === "Unauthorized" ||
372
+ (typeof data?.data?.details === "string" &&
373
+ data.data.details.includes("401")));
374
+
375
+ if (is401) {
376
+ try {
377
+ const { refreshHeaders } = await import("../services/playwright.ts");
378
+ const { getBasicHeaders } = await import("../services/auth-playwright.ts");
379
+ const { loadAccounts } = await import("../core/accounts.ts");
380
+ const resolvedId = accountId ?? loadAccounts()[0]?.id;
381
+ if (resolvedId) {
382
+ console.warn(
383
+ `[Upload] STS token 401 — refreshing session with re-auth and retrying...`,
384
+ );
385
+ await refreshHeaders(resolvedId, undefined, true);
386
+ const fresh = await getBasicHeaders(resolvedId);
387
+ headers.cookie = fresh.cookie;
388
+ headers["user-agent"] = fresh.userAgent;
389
+ headers["bx-v"] = fresh.bxV;
390
+ if (fresh.bxUa) headers["bx-ua"] = fresh.bxUa;
391
+ if (fresh.bxUmidtoken) headers["bx-umidtoken"] = fresh.bxUmidtoken;
392
+ response = await doFetch(headers);
393
+ if (response.ok) {
394
+ data = await response.json().catch(() => null);
395
+ }
396
+ }
397
+ } catch (refreshErr) {
398
+ console.warn(
399
+ `[Upload] Re-auth for STS token failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`,
400
+ );
401
+ }
402
+ }
403
+
404
+ if (!data?.success || !data?.data) {
369
405
  throw new Error(
370
406
  `STS token invalid: ${JSON.stringify(data).substring(0, 200)}`,
371
407
  );
@@ -617,6 +653,7 @@ export async function processImagesForQwen(
617
653
  file_url?: { url: string };
618
654
  }>,
619
655
  headers: Record<string, string>,
656
+ accountId?: string,
620
657
  ): Promise<{ text: string; files: QwenFileEntry[] }> {
621
658
  const textParts: string[] = [];
622
659
  const files: QwenFileEntry[] = [];
@@ -654,6 +691,7 @@ export async function processImagesForQwen(
654
691
  fileSize,
655
692
  typeInfo.qwenFileType,
656
693
  headers,
694
+ accountId,
657
695
  );
658
696
  fileUrl = await uploadToOSS(remoteMedia.buffer, stsData, filename);
659
697
  fileId = stsData.file_id;
@@ -686,6 +724,7 @@ export async function processImagesForQwen(
686
724
  fileSize,
687
725
  typeInfo.qwenFileType,
688
726
  headers,
727
+ accountId,
689
728
  );
690
729
  fileUrl = await uploadToOSS(buffer, stsData, filename);
691
730
  fileId = stsData.file_id;
@@ -94,36 +94,13 @@ export async function getBasicHeaders(accountId?: string): Promise<{
94
94
  return getPlaywrightBasicHeaders(resolvedAccountId);
95
95
  }
96
96
 
97
- export function isTokenExpiringSoon(
98
- cookie: string,
99
- minutesBeforeExpiry = 5,
100
- ): boolean {
101
- const tokenMatch = cookie.match(/token=([^;]+)/);
102
- if (!tokenMatch) return false;
103
-
104
- try {
105
- const token = decodeURIComponent(tokenMatch[1]);
106
- const segments = token.split(".");
107
- // Some Qwen deployments use opaque cookies. Treating those as expired
108
- // forces expensive header capture on every personalization request.
109
- if (segments.length !== 3 || !segments[1]) return false;
110
-
111
- const payloadJson = Buffer.from(segments[1], "base64url").toString("utf-8");
112
- const payload = JSON.parse(payloadJson);
113
- const exp = payload.exp;
114
- if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
115
-
116
- const nowSec = Math.floor(Date.now() / 1000);
117
- const thresholdSec = minutesBeforeExpiry * 60;
118
- return exp - nowSec < thresholdSec;
119
- } catch {
120
- return false;
121
- }
122
- }
97
+ import { parseJwtExpiry, isTokenExpiringSoon } from "../utils/jwt.ts";
98
+ export { parseJwtExpiry, isTokenExpiringSoon };
123
99
 
124
100
  export async function getQwenHeaders(
125
101
  forceNew = false,
126
102
  accountId?: string,
103
+ forceReauth = false,
127
104
  ): Promise<HeaderResult> {
128
105
  if (isAuthMockEnabled()) {
129
106
  const basic = await getBasicHeaders(accountId);
@@ -150,8 +127,8 @@ export async function getQwenHeaders(
150
127
 
151
128
  await ensurePlaywrightInitialized(resolvedAccountId);
152
129
 
153
- if (forceNew) {
154
- await refreshHeaders(resolvedAccountId);
130
+ if (forceNew || forceReauth) {
131
+ await refreshHeaders(resolvedAccountId, config.timeouts.headers, forceReauth);
155
132
  }
156
133
 
157
134
  const basic = await getPlaywrightBasicHeaders(resolvedAccountId);