qwenproxy-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. package/src/utils/types.ts +109 -0
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * QwenProxy - Unified CLI Entrypoint & Runner
5
+ *
6
+ * Commands:
7
+ * qwenproxy (default) -> Launches the interactive TUI management dashboard and proxy
8
+ * qwenproxy --server -> Runs headless proxy server in background
9
+ * qwenproxy sync -> Synchronizes AI coding clients (Claude Code, Codex, OpenCode, OMP)
10
+ * qwenproxy clean -> Prunes transient caches
11
+ * qwenproxy clean:all -> Reclaims unused Playwright browsers and caches
12
+ * qwenproxy reset -> Resets rate-limit and auth cooldowns
13
+ * qwenproxy purge -> Deletes remote chats across configured accounts
14
+ * qwenproxy login -> Authenticates accounts via visible browser
15
+ */
16
+
17
+ import { spawn, spawnSync } from "node:child_process";
18
+ import path from "node:path";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { createRequire } from "node:module";
21
+ import fs from "node:fs";
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const packageRoot = path.resolve(__dirname, "..");
26
+ const packageJsonPath = path.join(packageRoot, "package.json");
27
+
28
+ const rawArgs = process.argv.slice(2);
29
+ const firstArg = rawArgs[0]?.toLowerCase();
30
+
31
+ // 1. Version check
32
+ if (rawArgs.includes("-v") || rawArgs.includes("--version") || firstArg === "version") {
33
+ try {
34
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
35
+ console.log(`QwenProxy v${pkg.version || "1.0.0"}`);
36
+ } catch {
37
+ console.log("QwenProxy v1.0.0");
38
+ }
39
+ process.exit(0);
40
+ }
41
+
42
+ // 2. Help check
43
+ if (rawArgs.includes("-h") || rawArgs.includes("--help") || firstArg === "help") {
44
+ console.log(`
45
+ QwenProxy — High-performance AI Coding Gateway & Management TUI
46
+
47
+ Usage:
48
+ qwenproxy [options] [command]
49
+ qpx [options] [command]
50
+
51
+ Commands:
52
+ (default) Launch interactive TUI management dashboard and proxy
53
+ start Run headless HTTP/SSE proxy server
54
+ login Authenticate new accounts via visible browser
55
+ sync Synchronize AI clients (Claude Code, Codex, OpenCode, OMP)
56
+ clean Prune transient profile caches
57
+ clean:all Prune profile caches and clean unused Playwright browsers
58
+ reset Reset rate-limit and auth cooldowns in database
59
+ purge Delete remote chats across configured accounts
60
+
61
+ Options:
62
+ --tui Open interactive TUI dashboard (default)
63
+ --server Run in headless server mode
64
+ --port <num> Override server listening port (default: 7936)
65
+ -v, --version Show version number
66
+ -h, --help Show this help message
67
+ `);
68
+ process.exit(0);
69
+ }
70
+
71
+ // 3. Command dispatcher
72
+ let scriptFile = "src/index.ts";
73
+ let scriptArgs = [];
74
+
75
+ if (firstArg === "start" || rawArgs.includes("--server")) {
76
+ scriptFile = "src/index.ts";
77
+ scriptArgs = rawArgs.filter((a) => a !== "start" && a !== "--server");
78
+ } else if (firstArg === "tui" || rawArgs.includes("--tui") || rawArgs.length === 0) {
79
+ scriptFile = "src/index.ts";
80
+ scriptArgs = ["--tui", ...rawArgs.filter((a) => a !== "tui" && a !== "--tui")];
81
+ } else if (firstArg === "sync") {
82
+ scriptFile = "src/sync-clients.ts";
83
+ scriptArgs = rawArgs.slice(1);
84
+ } else if (firstArg === "clean") {
85
+ scriptFile = "src/clean-cache.ts";
86
+ scriptArgs = rawArgs.slice(1);
87
+ } else if (firstArg === "clean:all") {
88
+ scriptFile = "src/clean-cache.ts";
89
+ scriptArgs = ["--all", ...rawArgs.slice(1)];
90
+ } else if (firstArg === "reset") {
91
+ scriptFile = "src/reset-cooldowns.ts";
92
+ scriptArgs = rawArgs.slice(1);
93
+ } else if (firstArg === "purge") {
94
+ scriptFile = "src/delete-chats.ts";
95
+ scriptArgs = rawArgs.slice(1);
96
+ } else if (firstArg === "login") {
97
+ scriptFile = "src/login.ts";
98
+ scriptArgs = rawArgs.slice(1);
99
+ } else {
100
+ // Pass through remaining options to index.ts with default TUI flag
101
+ scriptFile = "src/index.ts";
102
+ scriptArgs = ["--tui", ...rawArgs];
103
+ }
104
+
105
+ const targetPath = path.resolve(packageRoot, scriptFile);
106
+
107
+ // Resolve tsx loader relative to the package installation rather than cwd
108
+ let tsxLoaderArg = "tsx";
109
+ try {
110
+ const tsxEntry = require.resolve("tsx");
111
+ tsxLoaderArg = pathToFileURL(tsxEntry).href;
112
+ } catch {}
113
+
114
+ // Ensure Playwright Chromium is installed for first-time global users
115
+ try {
116
+ const { chromium } = await import("patchright");
117
+ const execPath = chromium.executablePath();
118
+ if (!fs.existsSync(execPath)) {
119
+ console.log("⏳ [QwenProxy] Instalando o navegador Chromium pela primeira vez...");
120
+ spawnSync("npx", ["patchright", "install", "chromium"], {
121
+ stdio: "inherit",
122
+ shell: true,
123
+ });
124
+ console.log("✓ [QwenProxy] Navegador instalado com sucesso!\n");
125
+ }
126
+ } catch {}
127
+
128
+ const child = spawn(process.execPath, ["--import", tsxLoaderArg, targetPath, ...scriptArgs], {
129
+ stdio: "inherit",
130
+ cwd: process.cwd(),
131
+ env: process.env,
132
+ });
133
+
134
+ child.on("exit", (code, signal) => {
135
+ process.exit(code ?? (signal ? 1 : 0));
136
+ });
137
+
138
+ child.on("error", (err) => {
139
+ console.error("❌ [QwenProxy] Failed to execute CLI script:", err.message);
140
+ process.exit(1);
141
+ });
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "qwenproxy-cli",
3
+ "version": "1.0.0",
4
+ "description": "API compatível com OpenAI que conecta clientes ao Qwen via Playwright, com suporte a múltiplas contas, tool calling e sessões persistentes.",
5
+ "main": "src/index.ts",
6
+ "bin": {
7
+ "qwenproxy": "bin/qwenproxy.js",
8
+ "qpx": "bin/qwenproxy.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "!src/tests",
14
+ "!src/benchmarks",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "postinstall": "patchright install chromium",
20
+ "start": "tsx src/index.ts",
21
+ "tui": "npx tsx src/tui/index.ts",
22
+ "login": "npx tsx src/login.ts",
23
+ "sync": "npx tsx src/sync-clients.ts",
24
+ "clean": "npx tsx src/clean-cache.ts",
25
+ "clean:all": "npx tsx src/clean-cache.ts --all",
26
+ "reset": "npx tsx src/reset-cooldowns.ts",
27
+ "purge": "npx tsx src/delete-chats.ts",
28
+ "test": "npm run test:mock && npm run test:live",
29
+ "test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/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",
30
+ "test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/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",
31
+ "test:live": "tsx --test src/tests/concurrentChat.test.ts src/tests/agenticStress.test.ts",
32
+ "benchmark:proxy": "npx tsx src/benchmarks/proxy-baseline.ts",
33
+ "typecheck": "tsc --noEmit"
34
+ },
35
+ "keywords": [
36
+ "qwen",
37
+ "proxy",
38
+ "openai",
39
+ "anthropic",
40
+ "claude-code",
41
+ "codex",
42
+ "opencode",
43
+ "tui",
44
+ "patchright",
45
+ "playwright",
46
+ "llm"
47
+ ],
48
+ "homepage": "https://github.com/johngbl/qwenproxy#readme",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/johngbl/qwenproxy.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/johngbl/qwenproxy/issues"
55
+ },
56
+ "author": "johngbl <147551701+johngbl@users.noreply.github.com>",
57
+ "license": "ISC",
58
+ "dependencies": {
59
+ "@hono/node-server": "^2.1.1",
60
+ "ali-oss": "^6.23.0",
61
+ "better-sqlite3": "^13.0.3",
62
+ "dotenv": "^17.4.2",
63
+ "hono": "^4.13.7",
64
+ "patchright": "^1.62.3",
65
+ "tsx": "^4.23.13",
66
+ "uuid": "^14.0.2",
67
+ "zod": "^4.5.4"
68
+ },
69
+ "devDependencies": {
70
+ "@types/better-sqlite3": "^9.6.0",
71
+ "@types/node": "^26.4.1",
72
+ "typescript": "^7.0.2"
73
+ },
74
+ "type": "module",
75
+ "engines": {
76
+ "node": ">=22"
77
+ }
78
+ }
@@ -0,0 +1,159 @@
1
+ import { ZodError } from "zod";
2
+ import { logger } from "../core/logger.js";
3
+ import {
4
+ QwenProxyError,
5
+ InternalError,
6
+ ValidationError,
7
+ UpstreamRateLimit,
8
+ UpstreamError,
9
+ UpstreamTimeout,
10
+ ServiceUnavailable,
11
+ ClientAbortedError,
12
+ } from "../core/errors.js";
13
+ import {
14
+ QwenNetworkError,
15
+ QwenUpstreamUnavailableError,
16
+ RetryableQwenStreamError,
17
+ QwenUpstreamError,
18
+ QwenSessionExpiredError,
19
+ getQwenErrorCode,
20
+ } from "../services/qwen.js";
21
+
22
+ /**
23
+ * Classifies unknown errors into standard QwenProxyError hierarchy.
24
+ * Preserves specific error metadata when possible.
25
+ */
26
+ export function classifyError(err: unknown): QwenProxyError {
27
+ // These errors are retryable upstream failures, not rate-limit responses.
28
+ // RetryableQwenStreamError inherits UpstreamRateLimit for legacy behavior,
29
+ // so classify the concrete network/upstream types before that base class.
30
+ if (
31
+ err instanceof QwenNetworkError ||
32
+ err instanceof QwenUpstreamUnavailableError
33
+ ) {
34
+ return new UpstreamError(err.message);
35
+ }
36
+
37
+ if (err instanceof RetryableQwenStreamError) {
38
+ const upstreamCode = getQwenErrorCode(err)?.toLowerCase() || "";
39
+ const message = err.message.toLowerCase();
40
+
41
+ // Content moderation rejections are client-side content policy violations,
42
+ // not upstream failures. Return 400 so the client can adjust its input.
43
+ if (
44
+ upstreamCode === "data_inspection_failed" ||
45
+ message.includes("content moderation") ||
46
+ message.includes("data_inspection_failed")
47
+ ) {
48
+ const moderationError = new ValidationError(
49
+ `Content rejected by Qwen safety filter: ${err.message.replace(/^Qwen content moderation: [^:]+: /, "")}`,
50
+ );
51
+ (moderationError as any).code = "content_policy_violation";
52
+ return moderationError;
53
+ }
54
+
55
+ const isActualRateLimit =
56
+ upstreamCode === "quota_limit" ||
57
+ upstreamCode === "ratelimited" ||
58
+ upstreamCode === "rate_limit" ||
59
+ upstreamCode === "rate_limit_exceeded" ||
60
+ message.includes("quota") ||
61
+ message.includes("upper limit for today");
62
+
63
+ return isActualRateLimit
64
+ ? new UpstreamRateLimit(err.message)
65
+ : new UpstreamError(err.message);
66
+ }
67
+
68
+ if (err instanceof QwenUpstreamError) {
69
+ return err;
70
+ }
71
+
72
+ if (err instanceof QwenSessionExpiredError) {
73
+ return err;
74
+ }
75
+
76
+ if (err instanceof QwenProxyError) {
77
+ return err;
78
+ }
79
+
80
+ // Client disconnected before the stream could be created. This is not a
81
+ // server fault: the request has no listener anymore. Classify it as a silent
82
+ // abort (499) so callers neither emit a 500 nor count it as an error.
83
+ if (err instanceof Error && err.message.includes("client aborted")) {
84
+ return new ClientAbortedError(err.message);
85
+ }
86
+
87
+ // Capacity saturation on the bridge's own account pool: this is a "try again
88
+ // later" condition, not a server fault. A hard 500 is misleading for the
89
+ // client (and for the retry classifier it hides a recoverable slot wait).
90
+ if (
91
+ err instanceof Error &&
92
+ (err as Error & { code?: string }).code === "account_busy"
93
+ ) {
94
+ return new UpstreamRateLimit(err.message);
95
+ }
96
+
97
+ // Some call sites attach an explicit hint on a plain Error (e.g.
98
+ // acquireUpstreamStream sets upstreamStatus=429 when the whole pool is in
99
+ // cooldown). Respect it instead of falling through to a misleading 500.
100
+ const rawError = err as Error | null | undefined;
101
+
102
+ // A mutex acquire timeout (chat lock / personalization lock / playwright
103
+ // page lock) means the resource is BUSY, not broken — the holder is a
104
+ // legitimate long generation or a stuck-but-recoverable page op. Mapping it
105
+ // to 500 made every concurrent request on a long chat turn fail hard with
106
+ // internal_server_error (2026-08-22 production log). Surface as retryable
107
+ // 503 so the client (or the retry policy) can wait and re-request.
108
+ if (
109
+ rawError instanceof Error &&
110
+ rawError.message.startsWith("Mutex[") &&
111
+ rawError.message.includes("acquire timeout")
112
+ ) {
113
+ return new ServiceUnavailable(
114
+ `Resource busy (${rawError.message.substring(0, 200)}). Retry shortly.`,
115
+ );
116
+ }
117
+
118
+ const statusHint = (err as Error & { upstreamStatus?: number })?.upstreamStatus;
119
+ if (typeof statusHint === "number") {
120
+ const message =
121
+ rawError instanceof Error
122
+ ? rawError.message
123
+ : typeof err === "string"
124
+ ? err
125
+ : "Unknown upstream error";
126
+ switch (statusHint) {
127
+ case 429:
128
+ return new UpstreamRateLimit(message);
129
+ case 502:
130
+ return new UpstreamError(message);
131
+ case 503:
132
+ return new ServiceUnavailable(message);
133
+ case 504:
134
+ return new UpstreamTimeout(message);
135
+ default:
136
+ break; // unknown hint: fall through to the normal mapping
137
+ }
138
+ }
139
+
140
+ if (err instanceof ZodError) {
141
+ return new ValidationError(err.message);
142
+ }
143
+
144
+ if (
145
+ err instanceof Error &&
146
+ (err.message.includes("Target page, context or browser has been closed") ||
147
+ err.message.includes("browserType.launchPersistentContext"))
148
+ ) {
149
+ return new ServiceUnavailable(
150
+ "Falha ao inicializar o navegador Playwright para a conta. O perfil pode estar travado por outro processo ou o navegador fechou inesperadamente. Execute 'npm run reset'.",
151
+ );
152
+ }
153
+
154
+ logger.warn("Unclassified error mapped to InternalError", {
155
+ error: err instanceof Error ? err.message : String(err),
156
+ });
157
+
158
+ return new InternalError(err instanceof Error ? err.message : String(err));
159
+ }
@@ -0,0 +1,118 @@
1
+ import type { Context } from "hono";
2
+ import type { QwenProxyStatusCode } from "../core/errors.js";
3
+ import {
4
+ QwenProxyError,
5
+ ValidationError,
6
+ AuthError,
7
+ ForbiddenError,
8
+ NotFoundError,
9
+ UpstreamRateLimit,
10
+ UpstreamError,
11
+ UpstreamTimeout,
12
+ InternalError,
13
+ ClientAbortedError,
14
+ ServiceUnavailable,
15
+ } from "../core/errors.js";
16
+ import { classifyError } from "./error-classifier.js";
17
+
18
+ const VALID_STATUSES: ReadonlySet<number> = new Set([
19
+ 400, 401, 403, 404, 429, 499, 500, 502, 503, 504,
20
+ ]);
21
+
22
+ function isValidStatus(code: number): code is QwenProxyStatusCode {
23
+ return VALID_STATUSES.has(code);
24
+ }
25
+
26
+ function errorForStatus(
27
+ status: QwenProxyStatusCode,
28
+ message: string,
29
+ ): QwenProxyError {
30
+ switch (status) {
31
+ case 400:
32
+ return new ValidationError(message);
33
+ case 401:
34
+ return new AuthError(message);
35
+ case 403:
36
+ return new ForbiddenError(message);
37
+ case 404:
38
+ return new NotFoundError(message);
39
+ case 429:
40
+ return new UpstreamRateLimit(message);
41
+ case 499:
42
+ return new ClientAbortedError(message);
43
+ case 500:
44
+ return new InternalError(message);
45
+ case 502:
46
+ return new UpstreamError(message);
47
+ case 503:
48
+ return new ServiceUnavailable(message);
49
+ case 504:
50
+ return new UpstreamTimeout(message);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Sends a standardized OpenAI-compatible error response.
56
+ * Handles QwenProxyError directly, checks upstreamStatus hints on plain errors,
57
+ * and falls back to the error classifier.
58
+ */
59
+ export function sendOpenAIError(
60
+ c: Context,
61
+ err: unknown,
62
+ fallbackStatus?: QwenProxyStatusCode,
63
+ ): Response {
64
+ let qwenProxyErr: QwenProxyError;
65
+
66
+ if (err instanceof QwenProxyError) {
67
+ qwenProxyErr = err;
68
+ } else {
69
+ const hint = (err as Record<string, unknown>)?.upstreamStatus;
70
+ if (typeof hint === "number" && isValidStatus(hint)) {
71
+ qwenProxyErr = errorForStatus(
72
+ hint,
73
+ err instanceof Error ? err.message : String(err),
74
+ );
75
+ } else if (fallbackStatus) {
76
+ qwenProxyErr = errorForStatus(
77
+ fallbackStatus,
78
+ err instanceof Error ? err.message : String(err),
79
+ );
80
+ } else {
81
+ qwenProxyErr = classifyError(err);
82
+ }
83
+ }
84
+
85
+ const inner = qwenProxyErr.toOpenAI().error;
86
+ const body = {
87
+ error: {
88
+ message: inner.message,
89
+ type: inner.type,
90
+ code: inner.code,
91
+ param: inner.param ?? null,
92
+ },
93
+ };
94
+ // 499 (Client Closed Request) is not part of Hono's ContentfulStatusCode
95
+ // union. The abort is intercepted before sendOpenAIError in the chat route,
96
+ // but keep the fallback type-safe by building the Response directly.
97
+ if (qwenProxyErr.statusCode === 499) {
98
+ return new Response(JSON.stringify(body), {
99
+ status: 499,
100
+ headers: { "content-type": "application/json" },
101
+ });
102
+ }
103
+ return c.json(body, qwenProxyErr.statusCode);
104
+ }
105
+
106
+ /**
107
+ * Creates a QwenProxyError mapped to the given HTTP status code.
108
+ * Useful for inline error returns without throwing.
109
+ */
110
+ export function createError(
111
+ status: QwenProxyStatusCode,
112
+ message: string,
113
+ param?: string,
114
+ ): QwenProxyError {
115
+ const err = errorForStatus(status, message);
116
+ if (param) err.param = param;
117
+ return err;
118
+ }