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.
- package/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
package/bin/qwenproxy.js
ADDED
|
@@ -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
|
+
}
|