qwenproxy-cli 1.0.25 → 1.0.26

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.0.25",
3
+ "version": "1.0.26",
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": {
@@ -24,8 +24,8 @@
24
24
  "reset": "npx tsx src/reset-cooldowns.ts",
25
25
  "purge": "npx tsx src/delete-chats.ts",
26
26
  "test": "npm run test:mock && npm run test:live",
27
- "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/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/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/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/personalization-deadline.test.ts",
28
- "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/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/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/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/personalization-deadline.test.ts",
27
+ "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/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/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/personalization-deadline.test.ts",
28
+ "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/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/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/personalization-deadline.test.ts",
29
29
  "test:live": "tsx --test src/tests/concurrentChat.test.ts src/tests/agenticStress.test.ts",
30
30
  "benchmark:proxy": "npx tsx src/benchmarks/proxy-baseline.ts",
31
31
  "typecheck": "tsc --noEmit"
package/src/api/server.ts CHANGED
@@ -814,6 +814,9 @@ export async function startServer(options?: {
814
814
  await import("../core/account-concurrency.ts");
815
815
  startLeaseSweepTimer();
816
816
 
817
+ const { scheduleStartupChatCleanup } =
818
+ await import("../services/chat-cleanup.ts");
819
+ scheduleStartupChatCleanup();
817
820
  const serverInstance = serve({
818
821
  fetch: app.fetch,
819
822
  port: config.server.port,
@@ -161,7 +161,9 @@ const envSchema = z
161
161
  CONTEXT_METER_WINDOW_TOKENS: z.string().default("0"),
162
162
  CONTEXT_METER_REPORT_USAGE: z.string().default("true"),
163
163
  DELETE_ALL_CHATS_ON_SHUTDOWN: z.string().default("false"),
164
- // Keep idle account pages alive (subtlePageActivity + occasional reload).
164
+ AUTO_CLEAN_CHATS_ON_STARTUP: z.string().default("true"),
165
+ AUTO_CLEAN_ORPHAN_CHATS: z.string().default("true"),
166
+ AUTO_CLEAN_CHAT_MAX_AGE_HOURS: z.string().default("24"),
165
167
  // The Baxia WAF scores live page behavior (pointer/scroll events, open
166
168
  // session) — an account whose page sits frozen for minutes returns a low
167
169
  // trust score and gets TMD-challenged on the next request. On by default;
@@ -367,7 +369,12 @@ export const config = {
367
369
  parseInt(env.QWEN_MAX_PERSONALIZATION_BYTES),
368
370
  ),
369
371
  deleteAllChatsOnShutdown: env.DELETE_ALL_CHATS_ON_SHUTDOWN === "true",
370
- /** Send the captured bx-ua/bx-umidtoken headers (real client does NOT). */
372
+ autoCleanChatsOnStartup: env.AUTO_CLEAN_CHATS_ON_STARTUP !== "false",
373
+ autoCleanOrphanChats: env.AUTO_CLEAN_ORPHAN_CHATS !== "false",
374
+ autoCleanChatMaxAgeHours: Math.max(
375
+ 1,
376
+ parseInt(env.AUTO_CLEAN_CHAT_MAX_AGE_HOURS) || 24,
377
+ ),
371
378
  sendBxUa: env.QWEN_SEND_BX_UA === "true",
372
379
  /** Deployed web bundle version sent as the `version` API header. */
373
380
  webVersion: env.QWEN_WEB_VERSION,
@@ -36,6 +36,7 @@ import {
36
36
  } from "../../core/account-concurrency.ts";
37
37
  import { isAuthMockEnabled } from "../../services/auth-playwright.ts";
38
38
  import { isPlaywrightInitialized, refreshHeaders } from "../../services/playwright.ts";
39
+ import { enqueueOrphanChatDeletion } from "../../services/chat-cleanup.ts";
39
40
  import {
40
41
  clearAllSessionsForAccount,
41
42
  createQwenStream,
@@ -1720,12 +1721,15 @@ async function tryCreateStreamWithRetry(
1720
1721
  // Do NOT persist sticky binding until create succeeds — premature empty
1721
1722
  // chatSessionId writes make subsequent turns rotate/lose context.
1722
1723
  if (params.useThreadNative) {
1724
+ const abandonedChatId = params.existingThread?.chatSessionId;
1725
+ if (abandonedChatId) {
1726
+ enqueueOrphanChatDeletion(currentAccountId, abandonedChatId, policy.reason);
1727
+ }
1723
1728
  params.existingThread = null;
1724
1729
  params.finalPrompt = params.fullPrompt;
1725
1730
  params.messageCount = params.fullMessageCount ?? params.messageCount;
1726
1731
  params.forceNewChat = true;
1727
1732
  }
1728
-
1729
1733
  await new Promise((resolve) =>
1730
1734
  setTimeout(
1731
1735
  resolve,
@@ -1749,12 +1753,15 @@ async function tryCreateStreamWithRetry(
1749
1753
  console.warn(
1750
1754
  `🔄 [Chat] Forcing new chat/full context | reason=${policy.reason}`,
1751
1755
  );
1756
+ const abandonedChatId = params.existingThread?.chatSessionId;
1757
+ if (abandonedChatId) {
1758
+ enqueueOrphanChatDeletion(currentAccountId, abandonedChatId, policy.reason);
1759
+ }
1752
1760
  params.existingThread = null;
1753
1761
  params.finalPrompt = params.fullPrompt;
1754
1762
  params.messageCount = params.fullMessageCount ?? params.messageCount;
1755
1763
  params.forceNewChat = true;
1756
1764
  }
1757
-
1758
1765
  // Drop files on retry for invalid_input to isolate file-related errors
1759
1766
  if (policy.dropFiles && params.allFiles.length > 0) {
1760
1767
  console.warn(
@@ -3,14 +3,24 @@ import {
3
3
  loadAccounts,
4
4
  type QwenAccount,
5
5
  } from "../core/accounts.ts";
6
- import { deleteAllQwenChats } from "./qwen.ts";
6
+ import {
7
+ deleteAllQwenChats,
8
+ deleteSingleQwenChat,
9
+ fetchRemoteQwenChats,
10
+ } from "./qwen.ts";
7
11
  import {
8
12
  initPlaywrightForAccount,
9
13
  isPlaywrightInitialized,
10
14
  closeAllPlaywright,
15
+ getActivePlaywrightAccountIds,
11
16
  } from "./playwright.ts";
12
17
  import { isAuthMockEnabled } from "./auth-playwright.ts";
13
- import { maskEmail } from "../core/logger.ts";
18
+ import { maskEmail, logger } from "../core/logger.ts";
19
+ import { config } from "../core/config.ts";
20
+ import { isAccountBusy } from "../core/account-concurrency.ts";
21
+ import { isChatSessionActive } from "./qwen-thread-state.ts";
22
+ import { sleep } from "./human-behavior.ts";
23
+
14
24
  export interface DeleteChatsResult {
15
25
  attempted: number;
16
26
  succeeded: number;
@@ -109,10 +119,169 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
109
119
  });
110
120
  }
111
121
  }
112
-
113
122
  return {
114
123
  attempted: accounts.length,
115
124
  succeeded,
116
125
  mode: "accounts",
117
126
  };
118
127
  }
128
+
129
+ interface OrphanChatEntry {
130
+ accountId: string;
131
+ chatId: string;
132
+ reason: string;
133
+ enqueuedAt: number;
134
+ }
135
+
136
+ const orphanChatQueue: OrphanChatEntry[] = [];
137
+ let isProcessingQueue = false;
138
+
139
+ export function getOrphanQueueLengthForTests(): number {
140
+ return orphanChatQueue.length;
141
+ }
142
+
143
+ /**
144
+ * Enqueues an abandoned/orphan chat for asynchronous deletion in background.
145
+ * Never blocks the calling request and never deletes a chat actively in use.
146
+ */
147
+ export function enqueueOrphanChatDeletion(
148
+ accountId: string,
149
+ chatId: string,
150
+ reason = "orphan",
151
+ ): void {
152
+ if (!config.qwen.autoCleanOrphanChats || !accountId || !chatId) return;
153
+ if (orphanChatQueue.some((e) => e.accountId === accountId && e.chatId === chatId)) {
154
+ return;
155
+ }
156
+ orphanChatQueue.push({
157
+ accountId,
158
+ chatId,
159
+ reason,
160
+ enqueuedAt: Date.now(),
161
+ });
162
+
163
+ if (!isProcessingQueue) {
164
+ const timer = setTimeout(() => {
165
+ void processOrphanChatQueue().catch(() => {});
166
+ }, 1_000);
167
+ timer.unref?.();
168
+ }
169
+ }
170
+
171
+ async function processOrphanChatQueue(): Promise<void> {
172
+ if (isProcessingQueue) return;
173
+ isProcessingQueue = true;
174
+ try {
175
+ while (orphanChatQueue.length > 0) {
176
+ const entry = orphanChatQueue[0];
177
+
178
+ // Never delete a chat that is currently actively bound to an ongoing session
179
+ if (isChatSessionActive(entry.chatId)) {
180
+ orphanChatQueue.shift();
181
+ continue;
182
+ }
183
+
184
+ // If the account is actively serving a stream, postpone deletion
185
+ if (!isAuthMockEnabled() && isAccountBusy(entry.accountId)) {
186
+ await sleep(2_000);
187
+ continue;
188
+ }
189
+
190
+ try {
191
+ const ok = await deleteSingleQwenChat(entry.accountId, entry.chatId);
192
+ if (ok) {
193
+ logger.debug("[ChatCleanup] Deleted orphan chat", {
194
+ accountId: entry.accountId,
195
+ chatId: entry.chatId,
196
+ reason: entry.reason,
197
+ });
198
+ }
199
+ } catch (err) {
200
+ logger.debug("[ChatCleanup] Failed to delete orphan chat", {
201
+ accountId: entry.accountId,
202
+ chatId: entry.chatId,
203
+ error: err instanceof Error ? err.message : String(err),
204
+ });
205
+ }
206
+
207
+ orphanChatQueue.shift();
208
+ if (orphanChatQueue.length > 0) {
209
+ await sleep(300);
210
+ }
211
+ }
212
+ } finally {
213
+ isProcessingQueue = false;
214
+ }
215
+ }
216
+
217
+ export async function drainOrphanChatQueueForTests(): Promise<void> {
218
+ await processOrphanChatQueue();
219
+ }
220
+
221
+ /**
222
+ * Scan an account's remote chat list and delete chats older than maxAgeHours,
223
+ * skipping any chat that is currently actively bound to a session.
224
+ */
225
+ export async function cleanOldChatsForAccount(
226
+ accountId: string,
227
+ maxAgeHours = config.qwen.autoCleanChatMaxAgeHours,
228
+ ): Promise<number> {
229
+ try {
230
+ const chats = await fetchRemoteQwenChats(accountId);
231
+ if (!Array.isArray(chats) || chats.length === 0) return 0;
232
+
233
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
234
+ const now = Date.now();
235
+ let cleaned = 0;
236
+
237
+ for (const chat of chats) {
238
+ if (!chat?.id) continue;
239
+ if (isChatSessionActive(chat.id)) continue;
240
+
241
+ const updatedMs =
242
+ typeof chat.updated_at === "number"
243
+ ? (chat.updated_at > 1e11 ? chat.updated_at : chat.updated_at * 1000)
244
+ : new Date(chat.updated_at).getTime();
245
+
246
+ if (now - updatedMs >= maxAgeMs) {
247
+ const ok = await deleteSingleQwenChat(accountId, chat.id);
248
+ if (ok) {
249
+ cleaned++;
250
+ await sleep(300);
251
+ }
252
+ }
253
+ }
254
+
255
+ if (cleaned > 0) {
256
+ console.log(
257
+ `🧹 [ChatCleanup] Remotely cleaned ${cleaned} old chat(s) (> ${maxAgeHours}h) for account ${accountId}`,
258
+ );
259
+ }
260
+ return cleaned;
261
+ } catch (error) {
262
+ logger.debug("[ChatCleanup] cleanOldChatsForAccount error", {
263
+ accountId,
264
+ error: error instanceof Error ? error.message : String(error),
265
+ });
266
+ return 0;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Startup hook: runs 15s after startup in the background, cleaning old chats
272
+ * for active accounts without holding up the server or user requests.
273
+ */
274
+ export function scheduleStartupChatCleanup(): void {
275
+ if (!config.qwen.autoCleanChatsOnStartup || isAuthMockEnabled()) return;
276
+
277
+ const timer = setTimeout(async () => {
278
+ try {
279
+ const activeIds = getActivePlaywrightAccountIds();
280
+ for (const accountId of activeIds) {
281
+ await cleanOldChatsForAccount(accountId);
282
+ await sleep(1_000);
283
+ }
284
+ } catch {}
285
+ }, 15_000);
286
+ timer.unref?.();
287
+ }
@@ -376,6 +376,43 @@ export function clearAllSessionsForAccount(accountId: string): void {
376
376
  );
377
377
  }
378
378
 
379
+ /**
380
+ * Returns true if a chatSessionId is currently actively bound to any non-expired session.
381
+ * Prevents auto-cleanup routines from deleting conversations currently in use.
382
+ */
383
+ export function isChatSessionActive(chatId: string): boolean {
384
+ if (!chatId) return false;
385
+ const now = Date.now();
386
+ for (const entry of logicalThreadStates.values()) {
387
+ if (entry.chatSessionId === chatId && now - entry.timestamp <= SESSION_TTL_MS) {
388
+ return true;
389
+ }
390
+ }
391
+ const sessionEntry = sessionStates.get(chatId);
392
+ if (sessionEntry && now - sessionEntry.timestamp <= SESSION_TTL_MS) {
393
+ return true;
394
+ }
395
+ return false;
396
+ }
397
+
398
+ /**
399
+ * Invalidate and remove all local cached/database records for a given chatId.
400
+ */
401
+ export function removeSessionByChatId(chatId: string): void {
402
+ if (!chatId) return;
403
+ sessionStates.delete(chatId);
404
+ for (const [key, entry] of logicalThreadStates.entries()) {
405
+ if (entry.chatSessionId === chatId) {
406
+ logicalThreadStates.delete(key);
407
+ logicalThreadDirty.delete(key);
408
+ }
409
+ }
410
+ try {
411
+ const db = getDatabase();
412
+ db.prepare("DELETE FROM logical_thread_states WHERE chat_session_id = ?").run(chatId);
413
+ } catch {}
414
+ }
415
+
379
416
  export function getSessionParent(
380
417
  sessionId: string,
381
418
  accountId?: string,
@@ -53,6 +53,8 @@ export {
53
53
  updateSessionParent,
54
54
  invalidateLogicalThreadParent,
55
55
  clearAllSessionsForAccount,
56
+ removeSessionByChatId,
57
+ isChatSessionActive,
56
58
  getSessionParent,
57
59
  } from "./qwen-thread-state.ts";
58
60
  export type { LogicalThreadEntry } from "./qwen-thread-state.ts";
@@ -74,6 +76,8 @@ import {
74
76
  } from "./qwen-errors.ts";
75
77
  import {
76
78
  clearAllSessionsForAccount,
79
+ removeSessionByChatId,
80
+ isChatSessionActive,
77
81
  getSessionParent,
78
82
  updateSessionParent,
79
83
  } from "./qwen-thread-state.ts";
@@ -1977,6 +1981,112 @@ export async function deleteAllQwenChats(accountId?: string): Promise<boolean> {
1977
1981
  return true;
1978
1982
  }
1979
1983
 
1984
+ export interface RemoteQwenChat {
1985
+ id: string;
1986
+ title: string;
1987
+ updated_at: number | string;
1988
+ created_at: number | string;
1989
+ }
1990
+
1991
+ /**
1992
+ * Deletes a single chat session by ID on Qwen Web.
1993
+ */
1994
+ export async function deleteSingleQwenChat(
1995
+ accountId: string | undefined,
1996
+ chatId: string,
1997
+ ): Promise<boolean> {
1998
+ if (!chatId) return false;
1999
+
2000
+ if (isAuthMockEnabled()) {
2001
+ const url = qwenUrl(`/api/v2/chats/${encodeURIComponent(chatId)}`);
2002
+ const response = await fetch(url, {
2003
+ method: "DELETE",
2004
+ });
2005
+ removeSessionByChatId(chatId);
2006
+ return response.ok;
2007
+ }
2008
+
2009
+ const requestHeaders: Record<string, string> = {
2010
+ source: "web",
2011
+ version: "0.2.89",
2012
+ timezone: new Date().toString().split(" (")[0],
2013
+ "x-request-id": crypto.randomUUID(),
2014
+ Referer: qwenUrl(`/c/${encodeURIComponent(chatId)}`),
2015
+ };
2016
+
2017
+ try {
2018
+ const response = await requestQwenTextInBrowser(
2019
+ accountId,
2020
+ "DELETE",
2021
+ `/api/v2/chats/${encodeURIComponent(chatId)}`,
2022
+ requestHeaders,
2023
+ undefined,
2024
+ { referrer: qwenUrl(`/c/${encodeURIComponent(chatId)}`), noMutexRecovery: true },
2025
+ );
2026
+
2027
+ const { json: parsed } = await readJsonTextResponse(response, {
2028
+ strict: false,
2029
+ });
2030
+
2031
+ const success = response.ok && parsed?.success === true && parsed?.data?.status === true;
2032
+ if (success) {
2033
+ removeSessionByChatId(chatId);
2034
+ }
2035
+ return success;
2036
+ } catch (error) {
2037
+ logger.debug("[Qwen] deleteSingleQwenChat failed", {
2038
+ accountId,
2039
+ chatId,
2040
+ error: error instanceof Error ? error.message : String(error),
2041
+ });
2042
+ return false;
2043
+ }
2044
+ }
2045
+
2046
+ /**
2047
+ * Fetch remote chat list from Qwen Web.
2048
+ */
2049
+ export async function fetchRemoteQwenChats(
2050
+ accountId?: string,
2051
+ ): Promise<RemoteQwenChat[]> {
2052
+ if (isAuthMockEnabled()) {
2053
+ try {
2054
+ const response = await fetch(qwenUrl("/api/v2/chats/?page=1&exclude_project=true"));
2055
+ const json: any = await response.json().catch(() => null);
2056
+ if (json?.success && Array.isArray(json.data)) {
2057
+ return json.data;
2058
+ }
2059
+ } catch {}
2060
+ return [];
2061
+ }
2062
+
2063
+ const requestHeaders: Record<string, string> = {
2064
+ version: "0.2.89",
2065
+ timezone: new Date().toString().split(" (")[0],
2066
+ "x-request-id": crypto.randomUUID(),
2067
+ Referer: qwenUrl("/settings/chats"),
2068
+ };
2069
+
2070
+ try {
2071
+ const response = await requestQwenTextInBrowser(
2072
+ accountId,
2073
+ "GET",
2074
+ "/api/v2/chats/?page=1&exclude_project=true",
2075
+ requestHeaders,
2076
+ undefined,
2077
+ { referrer: qwenUrl("/settings/chats"), noMutexRecovery: true },
2078
+ );
2079
+ if (!response.ok) return [];
2080
+ const json: any = await response.json().catch(() => null);
2081
+ if (json?.success && Array.isArray(json.data)) {
2082
+ return json.data;
2083
+ }
2084
+ return [];
2085
+ } catch {
2086
+ return [];
2087
+ }
2088
+ }
2089
+
1980
2090
  export async function fetchQwenModels(
1981
2091
  accountId?: string,
1982
2092
  ): Promise<PublicQwenModel[]> {