qwenproxy-cli 1.0.25 → 1.0.27

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.27",
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/tui-settings.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/tui-settings.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
@@ -22,6 +22,11 @@ import { AuthError, NotFoundError } from "../core/errors.js";
22
22
  import type { QwenAccount } from "../core/accounts.js";
23
23
  import { isAuthMockEnabled } from "../services/auth-playwright.js";
24
24
 
25
+ import {
26
+ hookServerConsoleForLogging,
27
+ getServerLogHistory,
28
+ subscribeServerLogStream,
29
+ } from "../core/server-log-buffer.ts";
25
30
  // Module-level state (initialized in startServer)
26
31
  let cache: MemoryCache | undefined;
27
32
  let watchdog: Watchdog | undefined;
@@ -131,12 +136,23 @@ app.use("*", async (c, next) => {
131
136
  String(Math.max(0, ratelimit.tokens - 1)),
132
137
  );
133
138
  c.header("x-ratelimit-reset-tokens", "0");
134
-
135
- metrics.increment("requests.total");
139
+ const isProbe =
140
+ c.req.path === "/health" ||
141
+ c.req.path === "/metrics" ||
142
+ c.req.path === "/logs" ||
143
+ c.req.path.startsWith("/logs") ||
144
+ c.req.path.startsWith("/diagnostics") ||
145
+ c.req.path === "/favicon.ico";
146
+
147
+ if (!isProbe) {
148
+ metrics.increment("requests.total");
149
+ }
136
150
  const start = Date.now();
137
151
  await next();
138
152
  const duration = Date.now() - start;
139
- metrics.histogram("latency.request", duration);
153
+ if (!isProbe) {
154
+ metrics.histogram("latency.request", duration);
155
+ }
140
156
  c.header("X-Response-Time", `${duration}ms`);
141
157
  c.header("openai-processing-ms", String(duration));
142
158
  });
@@ -272,6 +288,22 @@ app.get("/health", async (c) => {
272
288
  activeAccounts: (await import("../services/playwright.js")).getActivePlaywrightAccountIds(),
273
289
  metrics: {
274
290
  cache: await cache?.getStats(),
291
+ requestsTotal: Number(metrics.get("requests.total")?.value ?? 0),
292
+ requestsErrors: Number(metrics.get("requests.errors")?.value ?? 0),
293
+ latencyAvgMs: (() => {
294
+ const hist = metrics.get("latency.request")?.value;
295
+ if (hist && typeof hist === "object" && (hist as any).count > 0) {
296
+ return Math.round((hist as any).sum / (hist as any).count);
297
+ }
298
+ return 0;
299
+ })(),
300
+ deltasCount: Number(metrics.get("requests.delta")?.value ?? 0),
301
+ fullReplaysCount: Number(metrics.get("requests.full")?.value ?? 0),
302
+ toolCallsCount: Number(metrics.get("toolcalls.total")?.value ?? 0),
303
+ toolCallsRecovered: Number(metrics.get("toolcalls.recovered")?.value ?? 0),
304
+ captchasDetected: Number(metrics.get("captcha.challenges.detected")?.value ?? 0),
305
+ captchasSolved: Number(metrics.get("captcha.solves.succeeded")?.value ?? 0),
306
+ chatsCleaned: Number(metrics.get("chats.cleaned")?.value ?? 0),
275
307
  },
276
308
  });
277
309
  });
@@ -305,9 +337,54 @@ app.get("/metrics", (c) => {
305
337
  });
306
338
  });
307
339
 
340
+ app.get("/logs", (c) => {
341
+ return c.json(getServerLogHistory());
342
+ });
343
+
344
+ app.get("/logs/live", (c) => {
345
+ const encoder = new TextEncoder();
346
+ return c.body(
347
+ new ReadableStream({
348
+ start(controller) {
349
+ const past = getServerLogHistory();
350
+ for (const entry of past) {
351
+ try {
352
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(entry)}\n\n`));
353
+ } catch {}
354
+ }
355
+
356
+ const unsubscribe = subscribeServerLogStream((entry) => {
357
+ try {
358
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(entry)}\n\n`));
359
+ } catch {
360
+ unsubscribe();
361
+ }
362
+ });
363
+
364
+ c.req.raw.signal.addEventListener("abort", () => {
365
+ unsubscribe();
366
+ });
367
+ },
368
+ }),
369
+ 200,
370
+ {
371
+ "Content-Type": "text/event-stream",
372
+ "Cache-Control": "no-cache",
373
+ Connection: "keep-alive",
374
+ },
375
+ );
376
+ });
377
+
308
378
  app.onError((err, c) => {
309
379
  const requestId = c.req.header("X-Request-Id") || "unknown";
310
- metrics.increment("requests.errors");
380
+ const isProbe =
381
+ c.req.path === "/health" ||
382
+ c.req.path === "/metrics" ||
383
+ c.req.path.startsWith("/diagnostics") ||
384
+ c.req.path === "/favicon.ico";
385
+ if (!isProbe) {
386
+ metrics.increment("requests.errors");
387
+ }
311
388
  logger.error("API Error", {
312
389
  requestId,
313
390
  error: err instanceof Error ? err.message : String(err),
@@ -599,6 +676,7 @@ export async function startServer(options?: {
599
676
  installSignalHandlers?: boolean;
600
677
  showBanner?: boolean;
601
678
  }): Promise<StartedServerInfo> {
679
+ hookServerConsoleForLogging();
602
680
  if (server) {
603
681
  if (options?.installSignalHandlers !== false) installSignalHandlers();
604
682
  return buildStartedServerInfo();
@@ -814,6 +892,9 @@ export async function startServer(options?: {
814
892
  await import("../core/account-concurrency.ts");
815
893
  startLeaseSweepTimer();
816
894
 
895
+ const { scheduleStartupChatCleanup } =
896
+ await import("../services/chat-cleanup.ts");
897
+ scheduleStartupChatCleanup();
817
898
  const serverInstance = serve({
818
899
  fetch: app.fetch,
819
900
  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,
@@ -32,8 +32,13 @@ export class Metrics extends EventEmitter {
32
32
  // Core request metrics
33
33
  ["requests.total", "counter", "Total requests processed"],
34
34
  ["requests.errors", "counter", "Total request errors"],
35
+ ["requests.delta", "counter", "Thread-native delta requests"],
36
+ ["requests.full", "counter", "Full context replay or initial requests"],
35
37
  ["latency.request", "histogram", "Request latency (ms)"],
36
-
38
+ ["toolcalls.total", "counter", "Total tool calls processed"],
39
+ ["toolcalls.malformed", "counter", "Malformed tool calls detected"],
40
+ ["toolcalls.recovered", "counter", "Malformed tool calls recovered/healed"],
41
+ ["chats.cleaned", "counter", "Remote chats automatically cleaned"],
37
42
  // Stream metrics
38
43
  ["streams.active", "gauge", "Active SSE streams"],
39
44
  ["streams.errors", "counter", "Stream errors"],
package/src/core/paths.ts CHANGED
@@ -170,6 +170,13 @@ export function getSyncStatePath(customDataDir?: string): string {
170
170
  return path.join(customDataDir || getDataDir(), "sync-state.json");
171
171
  }
172
172
 
173
+ /**
174
+ * Path to TUI user preferences file (model, effort, last tab, etc.).
175
+ */
176
+ export function getTuiSettingsPath(customDataDir?: string): string {
177
+ return path.join(customDataDir || getDataDir(), "tui-settings.json");
178
+ }
179
+
173
180
  /**
174
181
  * Path to user configuration .env file (either local or in global data dir).
175
182
  */
@@ -0,0 +1,105 @@
1
+ /**
2
+ * QwenProxy - Server Log Circular Buffer & Live SSE Broadcast
3
+ * Captures server logs and streams them to connected TUI instances.
4
+ */
5
+
6
+ import { stripAnsi } from "../tui/theme.ts";
7
+
8
+ export interface ServerLogMessage {
9
+ time: string;
10
+ level: "INFO" | "WARN" | "ERROR";
11
+ message: string;
12
+ }
13
+
14
+ const MAX_LOG_HISTORY = 400;
15
+ const logHistory: ServerLogMessage[] = [];
16
+ const subscribers = new Set<(entry: ServerLogMessage) => void>();
17
+ let consoleHooked = false;
18
+
19
+ export function recordServerLog(level: "INFO" | "WARN" | "ERROR", text: string): void {
20
+ if (!text) return;
21
+ const clean = stripAnsi(text).trim();
22
+ if (!clean) return;
23
+
24
+ const time = new Date().toLocaleTimeString("pt-BR", {
25
+ hour: "2-digit",
26
+ minute: "2-digit",
27
+ second: "2-digit",
28
+ });
29
+
30
+ const lines = clean.split(/\r?\n/);
31
+ for (const raw of lines) {
32
+ const line = raw.trim();
33
+ if (!line) continue;
34
+ // Filter out raw ASCII box frames and border rows
35
+ if (/^[+\-=#]{5,}$/.test(line)) continue;
36
+ if (/^\|\s*\|$/.test(line)) continue;
37
+ if (line.startsWith("|") && line.endsWith("|")) continue;
38
+
39
+ // Filter out box remnants
40
+ if (
41
+ line === "QwenProxy" ||
42
+ line === "OpenAI & Anthropic Compatible API" ||
43
+ /^Endpoint\s+http/i.test(line) ||
44
+ /^Port\s+\d+/i.test(line) ||
45
+ /^Accounts\s+\d+\/\d+/i.test(line) ||
46
+ /^API Key\s+/i.test(line) ||
47
+ /^Status\s+●/i.test(line)
48
+ ) {
49
+ continue;
50
+ }
51
+
52
+ const entry: ServerLogMessage = { time, level, message: line };
53
+ logHistory.push(entry);
54
+ if (logHistory.length > MAX_LOG_HISTORY) {
55
+ logHistory.shift();
56
+ }
57
+
58
+ for (const sub of subscribers) {
59
+ try {
60
+ sub(entry);
61
+ } catch {}
62
+ }
63
+ }
64
+ }
65
+
66
+ export function getServerLogHistory(): ServerLogMessage[] {
67
+ return logHistory.slice();
68
+ }
69
+
70
+ export function subscribeServerLogStream(cb: (entry: ServerLogMessage) => void): () => void {
71
+ subscribers.add(cb);
72
+ return () => {
73
+ subscribers.delete(cb);
74
+ };
75
+ }
76
+
77
+ export function hookServerConsoleForLogging(): void {
78
+ if (consoleHooked) return;
79
+ consoleHooked = true;
80
+
81
+ const origLog = console.log;
82
+ const origInfo = console.info;
83
+ const origWarn = console.warn;
84
+ const origError = console.error;
85
+
86
+ console.log = (...args: any[]) => {
87
+ origLog(...args);
88
+ recordServerLog("INFO", args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
89
+ };
90
+
91
+ console.info = (...args: any[]) => {
92
+ origInfo(...args);
93
+ recordServerLog("INFO", args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
94
+ };
95
+
96
+ console.warn = (...args: any[]) => {
97
+ origWarn(...args);
98
+ recordServerLog("WARN", args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
99
+ };
100
+
101
+ console.error = (...args: any[]) => {
102
+ origError(...args);
103
+ recordServerLog("ERROR", args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
104
+ };
105
+ }
@@ -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(
@@ -22,6 +22,7 @@ import {
22
22
  } from "./streaming.ts";
23
23
  import { config, type ChatMode } from "../../core/config.ts";
24
24
  import { logger } from "../../core/logger.ts";
25
+ import { metrics } from "../../core/metrics.ts";
25
26
  import { getContextMeterHeaders, type ContextMeterMode } from "../../services/context-meter.ts";
26
27
  import {
27
28
  getLogicalThreadState,
@@ -277,6 +278,11 @@ export async function chatCompletions(c: Context) {
277
278
  console.log(
278
279
  `📤 [${routeLabel}] Request | req=${reqId} | ${streamResult.activeAccountLabel} | ${body.model} | ${replayed ? parsed.messageCount : msgCount} msg(s) | ${replayed ? fullPromptForRequest.length : finalPrompt.length} chars${replayed ? " | full-replay" : ""} | chat=${streamResult.uiSessionId.substring(0, 12)}${declaredTools.length ? ` | ${declaredTools.length} tool(s)` : ""}${files.length ? ` | ${files.length} file(s)` : ""} | +${Date.now() - reqStartedAt}ms`,
279
280
  );
281
+ if (replayed || !ctx.existingThread) {
282
+ metrics.increment("requests.full");
283
+ } else {
284
+ metrics.increment("requests.delta");
285
+ }
280
286
 
281
287
  const onAssistantComplete: ((event: AssistantCompleteEvent) => Promise<void> | void) | undefined = undefined;
282
288
 
@@ -3,14 +3,25 @@ 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
+ import { metrics } from "../core/metrics.ts";
24
+
14
25
  export interface DeleteChatsResult {
15
26
  attempted: number;
16
27
  succeeded: number;
@@ -109,10 +120,171 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
109
120
  });
110
121
  }
111
122
  }
112
-
113
123
  return {
114
124
  attempted: accounts.length,
115
125
  succeeded,
116
126
  mode: "accounts",
117
127
  };
118
128
  }
129
+
130
+ interface OrphanChatEntry {
131
+ accountId: string;
132
+ chatId: string;
133
+ reason: string;
134
+ enqueuedAt: number;
135
+ }
136
+
137
+ const orphanChatQueue: OrphanChatEntry[] = [];
138
+ let isProcessingQueue = false;
139
+
140
+ export function getOrphanQueueLengthForTests(): number {
141
+ return orphanChatQueue.length;
142
+ }
143
+
144
+ /**
145
+ * Enqueues an abandoned/orphan chat for asynchronous deletion in background.
146
+ * Never blocks the calling request and never deletes a chat actively in use.
147
+ */
148
+ export function enqueueOrphanChatDeletion(
149
+ accountId: string,
150
+ chatId: string,
151
+ reason = "orphan",
152
+ ): void {
153
+ if (!config.qwen.autoCleanOrphanChats || !accountId || !chatId) return;
154
+ if (orphanChatQueue.some((e) => e.accountId === accountId && e.chatId === chatId)) {
155
+ return;
156
+ }
157
+ orphanChatQueue.push({
158
+ accountId,
159
+ chatId,
160
+ reason,
161
+ enqueuedAt: Date.now(),
162
+ });
163
+
164
+ if (!isProcessingQueue) {
165
+ const timer = setTimeout(() => {
166
+ void processOrphanChatQueue().catch(() => {});
167
+ }, 1_000);
168
+ timer.unref?.();
169
+ }
170
+ }
171
+
172
+ async function processOrphanChatQueue(): Promise<void> {
173
+ if (isProcessingQueue) return;
174
+ isProcessingQueue = true;
175
+ try {
176
+ while (orphanChatQueue.length > 0) {
177
+ const entry = orphanChatQueue[0];
178
+
179
+ // Never delete a chat that is currently actively bound to an ongoing session
180
+ if (isChatSessionActive(entry.chatId)) {
181
+ orphanChatQueue.shift();
182
+ continue;
183
+ }
184
+
185
+ // If the account is actively serving a stream, postpone deletion
186
+ if (!isAuthMockEnabled() && isAccountBusy(entry.accountId)) {
187
+ await sleep(2_000);
188
+ continue;
189
+ }
190
+
191
+ try {
192
+ const ok = await deleteSingleQwenChat(entry.accountId, entry.chatId);
193
+ if (ok) {
194
+ metrics.increment("chats.cleaned");
195
+ logger.debug("[ChatCleanup] Deleted orphan chat", {
196
+ accountId: entry.accountId,
197
+ chatId: entry.chatId,
198
+ reason: entry.reason,
199
+ });
200
+ }
201
+ } catch (err) {
202
+ logger.debug("[ChatCleanup] Failed to delete orphan chat", {
203
+ accountId: entry.accountId,
204
+ chatId: entry.chatId,
205
+ error: err instanceof Error ? err.message : String(err),
206
+ });
207
+ }
208
+
209
+ orphanChatQueue.shift();
210
+ if (orphanChatQueue.length > 0) {
211
+ await sleep(300);
212
+ }
213
+ }
214
+ } finally {
215
+ isProcessingQueue = false;
216
+ }
217
+ }
218
+
219
+ export async function drainOrphanChatQueueForTests(): Promise<void> {
220
+ await processOrphanChatQueue();
221
+ }
222
+
223
+ /**
224
+ * Scan an account's remote chat list and delete chats older than maxAgeHours,
225
+ * skipping any chat that is currently actively bound to a session.
226
+ */
227
+ export async function cleanOldChatsForAccount(
228
+ accountId: string,
229
+ maxAgeHours = config.qwen.autoCleanChatMaxAgeHours,
230
+ ): Promise<number> {
231
+ try {
232
+ const chats = await fetchRemoteQwenChats(accountId);
233
+ if (!Array.isArray(chats) || chats.length === 0) return 0;
234
+
235
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
236
+ const now = Date.now();
237
+ let cleaned = 0;
238
+
239
+ for (const chat of chats) {
240
+ if (!chat?.id) continue;
241
+ if (isChatSessionActive(chat.id)) continue;
242
+
243
+ const updatedMs =
244
+ typeof chat.updated_at === "number"
245
+ ? (chat.updated_at > 1e11 ? chat.updated_at : chat.updated_at * 1000)
246
+ : new Date(chat.updated_at).getTime();
247
+
248
+ if (now - updatedMs >= maxAgeMs) {
249
+ const ok = await deleteSingleQwenChat(accountId, chat.id);
250
+ if (ok) {
251
+ cleaned++;
252
+ metrics.increment("chats.cleaned");
253
+ await sleep(300);
254
+ }
255
+ }
256
+ }
257
+
258
+ if (cleaned > 0) {
259
+ console.log(
260
+ `🧹 [ChatCleanup] Remotely cleaned ${cleaned} old chat(s) (> ${maxAgeHours}h) for account ${accountId}`,
261
+ );
262
+ }
263
+ return cleaned;
264
+ } catch (error) {
265
+ logger.debug("[ChatCleanup] cleanOldChatsForAccount error", {
266
+ accountId,
267
+ error: error instanceof Error ? error.message : String(error),
268
+ });
269
+ return 0;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Startup hook: runs 15s after startup in the background, cleaning old chats
275
+ * for active accounts without holding up the server or user requests.
276
+ */
277
+ export function scheduleStartupChatCleanup(): void {
278
+ if (!config.qwen.autoCleanChatsOnStartup || isAuthMockEnabled()) return;
279
+
280
+ const timer = setTimeout(async () => {
281
+ try {
282
+ const activeIds = getActivePlaywrightAccountIds();
283
+ for (const accountId of activeIds) {
284
+ await cleanOldChatsForAccount(accountId);
285
+ await sleep(1_000);
286
+ }
287
+ } catch {}
288
+ }, 15_000);
289
+ timer.unref?.();
290
+ }
@@ -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,