qwenproxy-cli 1.0.24 → 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.24",
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(
@@ -1841,6 +1841,34 @@ export async function processStreamingResponse(
1841
1841
  // executed, so the model can re-issue them.
1842
1842
  setToolCapNotice(logicalSessionId);
1843
1843
  await reader.cancel().catch(() => undefined);
1844
+ // Explicitly tell Qwen to stop generating on the backend so the upstream chat
1845
+ // settles immediately instead of remaining in "in progress" state for 30s.
1846
+ const capSessionId = currentUiSessionId || logicalSessionId;
1847
+ const capHeaders = getStream(completionId)?.headers;
1848
+ if (capSessionId && targetResponseId && capHeaders?.cookie && capHeaders["user-agent"]) {
1849
+ const capAccountId = currentAccountId;
1850
+ void requestQwenTextInBrowser(
1851
+ capAccountId,
1852
+ "POST",
1853
+ `/api/v2/chat/completions/stop?chat_id=${encodeURIComponent(capSessionId)}`,
1854
+ buildQwenRequestHeaders({
1855
+ cookie: capHeaders.cookie,
1856
+ userAgent: capHeaders["user-agent"],
1857
+ bxUa: capHeaders["bx-ua"],
1858
+ bxUmidtoken: capHeaders["bx-umidtoken"],
1859
+ bxV: capHeaders["bx-v"],
1860
+ chatSessionId: capSessionId,
1861
+ }),
1862
+ JSON.stringify({
1863
+ chat_id: capSessionId,
1864
+ response_id: targetResponseId,
1865
+ }),
1866
+ {
1867
+ referrer: qwenUrl(`/c/${encodeURIComponent(capSessionId)}`),
1868
+ noMutexRecovery: true,
1869
+ },
1870
+ ).catch(() => undefined);
1871
+ }
1844
1872
  }
1845
1873
 
1846
1874
  // Post-stream: error check + flush remaining content
@@ -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";
@@ -261,7 +265,10 @@ export function computeDynamicIdleTimeout(opts: {
261
265
  }): number {
262
266
  const payloadMB = opts.payloadSize / (1024 * 1024);
263
267
  const dynamic = opts.baseTimeoutMs + Math.ceil(payloadMB * 30_000);
264
- if (opts.parallelEscape && !opts.enableThinking) {
268
+ // The tight 15s cap is ONLY for small auxiliary requests (e.g. title generation).
269
+ // Larger parallel requests (such as Zed/OMP context compaction with big history)
270
+ // need the full dynamic timeout so they do not time out at 15s.
271
+ if (opts.parallelEscape && !opts.enableThinking && opts.payloadSize < 16_384) {
265
272
  return Math.min(15_000, dynamic);
266
273
  }
267
274
  return dynamic;
@@ -857,25 +864,33 @@ export async function requestQwenTextInBrowser(
857
864
 
858
865
  const evaluateRequest = (page: Page) =>
859
866
  page.evaluate(
860
- async ({ url, method, headers, body, referrer }: {
867
+ async ({ url, method, headers, body, referrer, timeoutMs }: {
861
868
  url: string;
862
869
  method: "GET" | "POST" | "DELETE";
863
870
  headers: Record<string, string>;
864
871
  body?: string;
865
872
  referrer?: string;
873
+ timeoutMs: number;
866
874
  }): Promise<BrowserTextResponse> => {
867
- const response = await fetch(url, {
868
- method,
869
- credentials: "include",
870
- headers,
871
- body,
872
- ...(referrer ? { referrer } : {}),
873
- });
874
- return {
875
- status: response.status,
876
- contentType: response.headers.get("content-type") || "",
877
- raw: await response.text(),
878
- };
875
+ const controller = new AbortController();
876
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
877
+ try {
878
+ const response = await fetch(url, {
879
+ method,
880
+ credentials: "include",
881
+ headers,
882
+ body,
883
+ signal: controller.signal,
884
+ ...(referrer ? { referrer } : {}),
885
+ });
886
+ return {
887
+ status: response.status,
888
+ contentType: response.headers.get("content-type") || "",
889
+ raw: await response.text(),
890
+ };
891
+ } finally {
892
+ clearTimeout(timeoutId);
893
+ }
879
894
  },
880
895
  {
881
896
  url,
@@ -883,6 +898,7 @@ export async function requestQwenTextInBrowser(
883
898
  headers: browserHeaders,
884
899
  body,
885
900
  referrer: options.referrer,
901
+ timeoutMs: options.timeoutMs ?? Math.min(config.timeouts.page, 20_000),
886
902
  },
887
903
  );
888
904
  const recoverOnTimeout = !options.noMutexRecovery;
@@ -1965,6 +1981,112 @@ export async function deleteAllQwenChats(accountId?: string): Promise<boolean> {
1965
1981
  return true;
1966
1982
  }
1967
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
+
1968
2090
  export async function fetchQwenModels(
1969
2091
  accountId?: string,
1970
2092
  ): Promise<PublicQwenModel[]> {
@@ -960,8 +960,8 @@ function repairCommonMalformedToolJson(content: string): string {
960
960
  '$1"arguments": ',
961
961
  )
962
962
  .replace(
963
- /([,{]\s*)arguments"\s*:/g,
964
- '$1"arguments":',
963
+ /([,{]\s*)([A-Za-z_][A-Za-z0-9_]*)"\s*:/g,
964
+ '$1"$2":',
965
965
  )
966
966
  .replace(
967
967
  /([,{]\s*)arguments\s*:\s*(?={|\[|")/g,