qwenproxy-cli 1.0.0 → 1.0.2

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 -14
  2. package/README.md +906 -906
  3. package/bin/qwenproxy.js +5 -1
  4. package/package.json +77 -78
  5. package/src/api/error-classifier.ts +159 -159
  6. package/src/api/error-helpers.ts +118 -118
  7. package/src/api/models.ts +261 -261
  8. package/src/api/server.ts +860 -859
  9. package/src/cache/memory-cache.ts +385 -385
  10. package/src/clean-cache.ts +204 -204
  11. package/src/core/account-concurrency.ts +671 -671
  12. package/src/core/account-manager.ts +301 -297
  13. package/src/core/account-priority.ts +163 -163
  14. package/src/core/accounts.ts +186 -186
  15. package/src/core/config.ts +383 -383
  16. package/src/core/crypto-utils.ts +79 -79
  17. package/src/core/database.ts +276 -276
  18. package/src/core/errors.ts +118 -118
  19. package/src/core/logger.ts +269 -269
  20. package/src/core/memory-usage.ts +84 -84
  21. package/src/core/metrics.ts +291 -291
  22. package/src/core/model-alias.ts +77 -77
  23. package/src/core/model-registry.ts +544 -544
  24. package/src/core/mutex.ts +119 -119
  25. package/src/core/paths.ts +199 -199
  26. package/src/core/prompt-limits.ts +214 -214
  27. package/src/core/reasoning-effort.ts +102 -102
  28. package/src/core/stream-registry.ts +96 -96
  29. package/src/core/waf-isolation.ts +117 -117
  30. package/src/core/watchdog.ts +195 -195
  31. package/src/delete-chats.ts +23 -23
  32. package/src/index.ts +65 -64
  33. package/src/login.ts +147 -147
  34. package/src/reset-cooldowns.ts +11 -11
  35. package/src/routes/anthropic/index.ts +355 -355
  36. package/src/routes/anthropic/translate.ts +522 -522
  37. package/src/routes/anthropic/types.ts +154 -154
  38. package/src/routes/anthropic/validation.ts +144 -144
  39. package/src/routes/chat/account.ts +1817 -1817
  40. package/src/routes/chat/context.ts +241 -241
  41. package/src/routes/chat/errors.ts +85 -85
  42. package/src/routes/chat/helpers.ts +268 -268
  43. package/src/routes/chat/index.ts +618 -618
  44. package/src/routes/chat/media.ts +285 -285
  45. package/src/routes/chat/retry-policy.ts +754 -754
  46. package/src/routes/chat/stop.ts +98 -98
  47. package/src/routes/chat/streaming.ts +2710 -2710
  48. package/src/routes/chat/validation.ts +526 -526
  49. package/src/routes/chat.ts +2 -2
  50. package/src/routes/completions.ts +290 -290
  51. package/src/routes/images.ts +139 -139
  52. package/src/routes/responses/adapter.ts +503 -503
  53. package/src/routes/responses/index.ts +405 -405
  54. package/src/routes/responses/state.ts +230 -230
  55. package/src/routes/responses/streaming.ts +528 -528
  56. package/src/routes/responses/types.ts +285 -285
  57. package/src/routes/responses/validation.ts +202 -202
  58. package/src/routes/upload.ts +731 -731
  59. package/src/routes/videos.ts +214 -214
  60. package/src/services/auth-playwright.ts +173 -173
  61. package/src/services/captcha-coordinator.ts +161 -161
  62. package/src/services/captcha-solver.ts +553 -553
  63. package/src/services/chat-cleanup.ts +80 -80
  64. package/src/services/context-meter.ts +317 -317
  65. package/src/services/fingerprint.ts +242 -242
  66. package/src/services/human-behavior.ts +173 -173
  67. package/src/services/media-generation.ts +1748 -1748
  68. package/src/services/playwright.ts +2878 -2800
  69. package/src/services/qwen-chat-pool.ts +345 -345
  70. package/src/services/qwen-errors.ts +133 -133
  71. package/src/services/qwen-headers.ts +79 -79
  72. package/src/services/qwen-thread-state.ts +393 -393
  73. package/src/services/qwen-url.ts +19 -19
  74. package/src/services/qwen.ts +3126 -3126
  75. package/src/services/session-keeper.ts +88 -88
  76. package/src/services/token-estimation-metrics.ts +118 -118
  77. package/src/sync/claude-code.ts +75 -75
  78. package/src/sync/codex.ts +123 -123
  79. package/src/sync/index.ts +362 -362
  80. package/src/sync/omp.ts +105 -105
  81. package/src/sync/opencode.ts +214 -214
  82. package/src/sync/types.ts +53 -53
  83. package/src/sync/utils.ts +27 -27
  84. package/src/sync-clients.ts +189 -189
  85. package/src/tools/instructions.ts +137 -137
  86. package/src/tools/manifest.ts +81 -81
  87. package/src/tools/parser.ts +2989 -2989
  88. package/src/tools/toolcall-tags.ts +142 -142
  89. package/src/tui/app.ts +259 -264
  90. package/src/tui/index.ts +61 -61
  91. package/src/tui/markdown.ts +258 -258
  92. package/src/tui/proxy-client.ts +331 -326
  93. package/src/tui/screen.ts +294 -278
  94. package/src/tui/server-manager.ts +270 -270
  95. package/src/tui/theme.ts +432 -432
  96. package/src/tui/types.ts +33 -33
  97. package/src/tui/views/accounts-view.ts +656 -656
  98. package/src/tui/views/chat-view.ts +1018 -823
  99. package/src/tui/views/logs-view.ts +479 -413
  100. package/src/tui/views/status-view.ts +204 -204
  101. package/src/tui/views/storage-view.ts +304 -291
  102. package/src/tui/views/sync-view.ts +409 -409
  103. package/src/types/ali-oss.d.ts +32 -32
  104. package/src/update-cli.ts +121 -0
  105. package/src/utils/context-truncation.ts +84 -84
  106. package/src/utils/json.ts +380 -380
  107. package/src/utils/session-id.ts +37 -37
  108. package/src/utils/tool-call-guard.ts +84 -84
  109. package/src/utils/types.ts +109 -109
package/src/api/server.ts CHANGED
@@ -1,859 +1,860 @@
1
- import crypto from "crypto";
2
- import net from "node:net";
3
- import { v4 as uuidv4 } from "uuid";
4
- import { Hono, type Context } from "hono";
5
- import { serve } from "@hono/node-server";
6
- import { config } from "../core/config.js";
7
- import { metrics } from "../core/metrics.js";
8
- import { logger, maskEmail } from "../core/logger.js";
9
- import { MemoryCache } from "../cache/memory-cache.js";
10
- import { Watchdog } from "../core/watchdog.js";
11
- import { getAccountCooldownInfo } from "../core/account-manager.js";
12
- import { app as modelsApp } from "./models.js";
13
- import { chatCompletions, chatCompletionsStop } from "../routes/chat.js";
14
- import { uploadFile } from "../routes/upload.js";
15
- import { imagesGenerations } from "../routes/images.js";
16
- import { videosGenerations, videoTaskStatus } from "../routes/videos.js";
17
- import { responsesApp } from "../routes/responses/index.js";
18
- import { completionsLegacy } from "../routes/completions.js";
19
- import { anthropicApp } from "../routes/anthropic/index.ts";
20
- import { sendOpenAIError } from "./error-helpers.js";
21
- import { AuthError, NotFoundError } from "../core/errors.js";
22
- import type { QwenAccount } from "../core/accounts.js";
23
- import { isAuthMockEnabled } from "../services/auth-playwright.js";
24
-
25
- // Module-level state (initialized in startServer)
26
- let cache: MemoryCache | undefined;
27
- let watchdog: Watchdog | undefined;
28
- let server: any;
29
- let startPromise: Promise<StartedServerInfo> | null = null;
30
- let stopPromise: Promise<void> | null = null;
31
- let signalHandlersInstalled = false;
32
-
33
- const app = new Hono();
34
-
35
- function formatAccountId(accountId: string): string {
36
- const normalized = accountId.trim();
37
- return normalized.length > 12 ? `${normalized.slice(0, 12)}…` : normalized;
38
- }
39
-
40
- function buildPortInUseMessage(port: number, host: string): string {
41
- return (
42
- `❌ [Server] Port ${port} is already in use (${host}:${port}).` +
43
- `\n Another QwenProxy instance (or another program) is listening on this port.` +
44
- `\n Stop the other instance first, or start on another port: PORT=3001 npm start`
45
- );
46
- }
47
-
48
- /**
49
- * Pre-flight port check run BEFORE the slow account warmup so a conflicting
50
- * listener fails in <1s with an explanatory message instead of crashing the
51
- * process minutes later after the warmup completes.
52
- */
53
- async function assertPortAvailable(): Promise<void> {
54
- const { port, host } = config.server;
55
- await new Promise<void>((resolve, reject) => {
56
- const probe = net.createServer();
57
- probe.once("error", (err: Error) => {
58
- const code = (err as NodeJS.ErrnoException).code;
59
- if (code === "EADDRINUSE") {
60
- reject(new Error(buildPortInUseMessage(port, host)));
61
- } else {
62
- reject(err);
63
- }
64
- });
65
- probe.once("listening", () => probe.close(() => resolve()));
66
- probe.listen(port, host);
67
- });
68
- }
69
-
70
- export function setCacheForTesting(nextCache: MemoryCache | undefined): void {
71
- cache = nextCache;
72
- }
73
-
74
- // Middleware must be registered BEFORE routes
75
-
76
- // CORS: browser-based clients (OpenWebUI, web frontends on another origin)
77
- // preflight before the Authorization header is sent, so OPTIONS short-circuits
78
- // BEFORE the /v1/* auth middleware. Default is permissive (doc checklist item
79
- // 2); set CORS_ORIGIN to lock it down.
80
- const corsOrigin = process.env.CORS_ORIGIN || "*";
81
- app.use("*", async (c, next) => {
82
- c.header("Access-Control-Allow-Origin", corsOrigin);
83
- c.header(
84
- "Access-Control-Allow-Methods",
85
- "GET, POST, PUT, PATCH, DELETE, OPTIONS",
86
- );
87
- c.header(
88
- "Access-Control-Allow-Headers",
89
- "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id",
90
- );
91
- c.header(
92
- "Access-Control-Expose-Headers",
93
- "X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
94
- );
95
- if (c.req.method === "OPTIONS") {
96
- // Hono does not merge c.header() values into a manually constructed
97
- // Response, so the preflight carries its CORS headers explicitly.
98
- return new Response(null, {
99
- status: 204,
100
- headers: {
101
- "Access-Control-Allow-Origin": corsOrigin,
102
- "Access-Control-Allow-Methods":
103
- "GET, POST, PUT, PATCH, DELETE, OPTIONS",
104
- "Access-Control-Allow-Headers":
105
- "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id",
106
- "Access-Control-Expose-Headers":
107
- "X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
108
- },
109
- });
110
- }
111
- await next();
112
- });
113
-
114
- app.use("*", async (c, next) => {
115
- const requestId = c.req.header("X-Request-Id") || uuidv4();
116
- c.header("X-Request-Id", requestId);
117
-
118
- // OpenAI-shaped response headers (doc §5.2): API version, processing time,
119
- // and static rate-limit windows so tools that parse x-ratelimit-* don't choke.
120
- c.header("openai-version", "2020-10-01");
121
- const ratelimit = config.server.rateLimit;
122
- c.header("x-ratelimit-limit-requests", String(ratelimit.requests));
123
- c.header(
124
- "x-ratelimit-remaining-requests",
125
- String(Math.max(0, ratelimit.requests - 1)),
126
- );
127
- c.header("x-ratelimit-reset-requests", "0");
128
- c.header("x-ratelimit-limit-tokens", String(ratelimit.tokens));
129
- c.header(
130
- "x-ratelimit-remaining-tokens",
131
- String(Math.max(0, ratelimit.tokens - 1)),
132
- );
133
- c.header("x-ratelimit-reset-tokens", "0");
134
-
135
- metrics.increment("requests.total");
136
- const start = Date.now();
137
- await next();
138
- const duration = Date.now() - start;
139
- metrics.histogram("latency.request", duration);
140
- c.header("X-Response-Time", `${duration}ms`);
141
- c.header("openai-processing-ms", String(duration));
142
- });
143
-
144
- function constantTimeStringEqual(provided: string, expected: string): boolean {
145
- const providedBuf = Buffer.from(provided);
146
- const expectedBuf = Buffer.from(expected);
147
- const providedHash = crypto.createHash("sha256").update(providedBuf).digest();
148
- const expectedHash = crypto.createHash("sha256").update(expectedBuf).digest();
149
-
150
- return (
151
- crypto.timingSafeEqual(providedHash, expectedHash) &&
152
- providedBuf.length === expectedBuf.length
153
- );
154
- }
155
-
156
- /**
157
- * Accept OpenAI-style Bearer and x-api-key.
158
- * Either may authenticate when API_KEY is configured.
159
- */
160
- function extractProvidedApiKeys(c: Context): string[] {
161
- const keys: string[] = [];
162
- const auth = c.req.header("Authorization");
163
- if (auth?.startsWith("Bearer ")) {
164
- const token = auth.slice(7).trim();
165
- if (token) keys.push(token);
166
- }
167
- const xApiKey = c.req.header("x-api-key")?.trim();
168
- if (xApiKey) keys.push(xApiKey);
169
- return keys;
170
- }
171
-
172
- function verifyApiKey(c: Context): Response | null {
173
- const apiKey = process.env.API_KEY || config.apiKey;
174
- if (!apiKey) return null;
175
-
176
- const candidates = extractProvidedApiKeys(c);
177
- const isAnthropic =
178
- c.req.path.startsWith("/v1/messages") ||
179
- !!c.req.header("anthropic-version");
180
-
181
- if (candidates.length === 0) {
182
- if (isAnthropic) {
183
- c.header("anthropic-version", c.req.header("anthropic-version") || "2023-06-01");
184
- return c.json(
185
- {
186
- type: "error",
187
- error: {
188
- type: "authentication_error",
189
- message: "Missing or invalid credentials (Authorization Bearer or x-api-key)",
190
- },
191
- },
192
- 401,
193
- );
194
- }
195
- return sendOpenAIError(
196
- c,
197
- new AuthError(
198
- "Missing or invalid credentials (Authorization Bearer or x-api-key)",
199
- ),
200
- );
201
- }
202
- if (candidates.some((token) => constantTimeStringEqual(token, apiKey))) {
203
- return null;
204
- }
205
- if (isAnthropic) {
206
- c.header("anthropic-version", c.req.header("anthropic-version") || "2023-06-01");
207
- return c.json(
208
- {
209
- type: "error",
210
- error: {
211
- type: "authentication_error",
212
- message: "Invalid API key",
213
- },
214
- },
215
- 401,
216
- );
217
- }
218
- return sendOpenAIError(c, new AuthError("Invalid API key"));
219
- }
220
- app.use("/v1/*", async (c, next) => {
221
- const error = verifyApiKey(c);
222
- if (error) return error;
223
- await next();
224
- });
225
-
226
- // Routes
227
- app.route("", modelsApp);
228
- app.post("/v1/chat/completions", chatCompletions);
229
- app.post("/v1/chat/completions/stop", chatCompletionsStop);
230
- app.post("/v1/completions", completionsLegacy);
231
- app.post("/v1/upload", uploadFile);
232
- app.post("/v1/images/generations", imagesGenerations);
233
- app.post("/v1/videos/generations", videosGenerations);
234
- app.get("/v1/tasks/status/:taskId", videoTaskStatus);
235
-
236
- // OpenAI Responses API compatible routes
237
- app.route("", responsesApp);
238
- app.route("", anthropicApp);
239
-
240
- // Accept paths without the /v1 prefix via a 308 redirect (method + body are
241
- // preserved on redirect). Most clients append /v1 themselves; the redirect
242
- // covers the rest without duplicating handlers.
243
- const LEGACY_REDIRECTS: Array<[string, string]> = [
244
- ["/chat/completions", "/v1/chat/completions"],
245
- ["/completions", "/v1/completions"],
246
- ["/responses", "/v1/responses"],
247
- ["/models", "/v1/models"],
248
- ["/messages", "/v1/messages"],
249
- ["/messages/count_tokens", "/v1/messages/count_tokens"],
250
- ];
251
- for (const [from, to] of LEGACY_REDIRECTS) {
252
- app.all(from, (c) => c.redirect(to, 308));
253
- }
254
-
255
- app.get("/health", async (c) => {
256
- const status = await watchdog?.getStatus();
257
- return c.json({
258
- status: status?.overall || "unknown",
259
- ram: status?.ram || "unknown",
260
- streams: status?.streams || "unknown",
261
- heap: status?.heap
262
- ? {
263
- used: status.heap.heapUsed,
264
- total: status.heap.heapTotal,
265
- limit: status.heap.heapSizeLimit,
266
- rss: status.heap.rss,
267
- usagePercent: Number(status.heap.usagePercent.toFixed(2)),
268
- }
269
- : undefined,
270
- timestamp: Date.now(),
271
- metrics: {
272
- cache: await cache?.getStats(),
273
- },
274
- });
275
- });
276
-
277
- // Token TTL diagnostics: inspect real cookie/header lifetimes
278
- app.get("/diagnostics/tokens", async (c) => {
279
- const error = verifyApiKey(c);
280
- if (error) return error;
281
-
282
- const { getTokenDiagnostics } = await import("../services/playwright.ts");
283
- const accountId = c.req.query("accountId");
284
-
285
- try {
286
- const diagnostics = await getTokenDiagnostics(accountId);
287
- return c.json(diagnostics);
288
- } catch (err) {
289
- return c.json(
290
- {
291
- error: err instanceof Error ? err.message : String(err),
292
- },
293
- 500,
294
- );
295
- }
296
- });
297
-
298
- app.get("/metrics", (c) => {
299
- const error = verifyApiKey(c);
300
- if (error) return error;
301
- return c.text(metrics.formatPrometheus(), {
302
- headers: { "Content-Type": "text/plain; version=0.0.4" },
303
- });
304
- });
305
-
306
- app.onError((err, c) => {
307
- const requestId = c.req.header("X-Request-Id") || "unknown";
308
- metrics.increment("requests.errors");
309
- logger.error("API Error", {
310
- requestId,
311
- error: err instanceof Error ? err.message : String(err),
312
- stack: err instanceof Error ? err.stack : undefined,
313
- });
314
- return sendOpenAIError(c, err);
315
- });
316
-
317
- app.notFound((c) => sendOpenAIError(c, new NotFoundError("Not found")));
318
-
319
- export interface StartedServerInfo {
320
- host: string;
321
- port: number;
322
- url: string;
323
- }
324
-
325
- function buildStartedServerInfo(): StartedServerInfo {
326
- const host =
327
- config.server.host === "0.0.0.0" ? "127.0.0.1" : config.server.host;
328
- return {
329
- host,
330
- port: config.server.port,
331
- url: `http://${host}:${config.server.port}`,
332
- };
333
- }
334
-
335
- function getErrorMessage(error: unknown): string {
336
- return error instanceof Error ? error.message : String(error);
337
- }
338
-
339
- async function warmConfiguredChatPools(
340
- warmQwenChatPool: (
341
- accountId: string | undefined,
342
- modelId: string,
343
- ) => Promise<void>,
344
- accountId?: string,
345
- ): Promise<void> {
346
- await Promise.all(
347
- config.qwen.chatPoolModels.map((model) =>
348
- warmQwenChatPool(accountId, model).catch(() => {}),
349
- ),
350
- );
351
- }
352
-
353
- async function prepareQwenRuntime(params: {
354
- accountId?: string;
355
- successMessage: string;
356
- failureMessage: string;
357
- initAuth: () => Promise<void>;
358
- disableNativeTools: (accountId?: string) => Promise<void>;
359
- warmQwenChatPool: (
360
- accountId: string | undefined,
361
- modelId: string,
362
- ) => Promise<void>;
363
- }): Promise<boolean> {
364
- if (params.accountId) {
365
- const { getAccountCooldownInfo } =
366
- await import("../core/account-manager.ts");
367
- const cooldownInfo = getAccountCooldownInfo(params.accountId);
368
- if (cooldownInfo) {
369
- console.warn(
370
- `⚠️ [Server] Account not ready | account=${formatAccountId(params.accountId)} | cooldown=${Math.ceil(cooldownInfo.remainingMs / 1000)}s | reason=${cooldownInfo.reason}`,
371
- );
372
- return false;
373
- }
374
- }
375
-
376
- try {
377
- await params.initAuth();
378
- await params.disableNativeTools(params.accountId).catch(() => {});
379
- await warmConfiguredChatPools(params.warmQwenChatPool, params.accountId);
380
- if (params.accountId) {
381
- const { getAccountCooldownInfo } =
382
- await import("../core/account-manager.ts");
383
- const cooldownInfo = getAccountCooldownInfo(params.accountId);
384
- if (cooldownInfo) {
385
- console.warn(
386
- `⚠️ [Server] Account not ready | account=${formatAccountId(params.accountId)} | cooldown=${Math.ceil(cooldownInfo.remainingMs / 1000)}s | reason=${cooldownInfo.reason}`,
387
- );
388
- return false;
389
- }
390
- }
391
- return true;
392
- } catch (error) {
393
- console.warn(`❌ ${params.failureMessage}`, getErrorMessage(error));
394
- if (params.accountId) {
395
- const { markAccountRateLimited } =
396
- await import("../core/account-manager.ts");
397
- markAccountRateLimited(
398
- params.accountId,
399
- config.concurrency.initFailureCooldownMs,
400
- "AuthInitFailed",
401
- );
402
- }
403
- return false;
404
- }
405
- }
406
-
407
- async function prepareAccountRuntime(
408
- account: QwenAccount,
409
- getAccountCredentials: (accountId: string) => QwenAccount | undefined,
410
- initPlaywrightForAccount: (
411
- account: QwenAccount,
412
- headless: boolean,
413
- browserType?: "chromium" | "chrome" | "edge",
414
- ) => Promise<void>,
415
- disableNativeTools: (accountId?: string) => Promise<void>,
416
- warmQwenChatPool: (
417
- accountId: string | undefined,
418
- modelId: string,
419
- ) => Promise<void>,
420
- ): Promise<boolean> {
421
- return prepareQwenRuntime({
422
- accountId: account.id,
423
- successMessage: `[Server] Account ready: ${maskEmail(account.email)}`,
424
- failureMessage: `[Server] Account init failed ${maskEmail(account.email)}:`,
425
- initAuth: () => {
426
- const credentials = getAccountCredentials(account.id);
427
- if (!credentials) {
428
- throw new Error(`Account ${account.id} credentials not found`);
429
- }
430
- return initPlaywrightForAccount(
431
- credentials,
432
- config.playwright.headless,
433
- config.playwright.browser,
434
- );
435
- },
436
- disableNativeTools,
437
- warmQwenChatPool,
438
- });
439
- }
440
-
441
- async function prepareRemainingAccountsInBackground(params: {
442
- accounts: QwenAccount[];
443
- batchSize: number;
444
- totalAccounts: number;
445
- getAccountCredentials: (accountId: string) => QwenAccount | undefined;
446
- initPlaywrightForAccount: (
447
- account: QwenAccount,
448
- headless: boolean,
449
- browserType?: "chromium" | "chrome" | "edge",
450
- ) => Promise<void>;
451
- disableNativeTools: (accountId?: string) => Promise<void>;
452
- warmQwenChatPool: (
453
- accountId: string | undefined,
454
- modelId: string,
455
- ) => Promise<void>;
456
- }): Promise<void> {
457
- const remaining = params.accounts;
458
- if (remaining.length === 0) return;
459
-
460
- // First account was already prepared successfully (displayed as 1/N),
461
- // so remaining accounts start at display index 2.
462
- let nextDisplayIndex = 2;
463
- for (let i = 0; i < remaining.length; i += params.batchSize) {
464
- const batch = remaining.slice(i, i + params.batchSize);
465
- const batchDisplayStart = nextDisplayIndex;
466
- nextDisplayIndex += batch.length;
467
-
468
- await Promise.all(
469
- batch.map((account, batchIndex) =>
470
- prepareAccountRuntime(
471
- account,
472
- params.getAccountCredentials,
473
- params.initPlaywrightForAccount,
474
- params.disableNativeTools,
475
- params.warmQwenChatPool,
476
- ).then((ok) => {
477
- if (ok) {
478
- const displayIndex = batchDisplayStart + batchIndex;
479
- console.log(
480
- `✅ [Server] Account ready (${displayIndex}/${params.totalAccounts}): ${maskEmail(account.email)}`,
481
- );
482
- }
483
- return ok;
484
- }),
485
- ),
486
- );
487
- }
488
- }
489
-
490
- async function cleanupServerResources(): Promise<void> {
491
- watchdog?.stop();
492
- watchdog = undefined;
493
- metrics.stopCollection();
494
-
495
- try {
496
- await cache?.close();
497
- } finally {
498
- cache = undefined;
499
- }
500
-
501
- try {
502
- const { stopSessionKeeper } = await import("../services/session-keeper.ts");
503
- stopSessionKeeper();
504
- } catch {
505
- // Session keeper may not have been initialized.
506
- }
507
-
508
- if (config.qwen.deleteAllChatsOnShutdown) {
509
- try {
510
- const { deleteChatsForConfiguredAccounts } =
511
- await import("../services/chat-cleanup.ts");
512
- const result = await deleteChatsForConfiguredAccounts();
513
- console.log(
514
- `🗑️ [Server] Deleted Qwen chats on shutdown: ${result.succeeded}/${result.attempted} scope(s)`,
515
- );
516
- } catch (error) {
517
- console.error(
518
- `❌ [Server] Failed to delete Qwen chats on shutdown:`,
519
- error instanceof Error ? error.message : String(error),
520
- );
521
- }
522
- }
523
-
524
- const { closeAllPlaywright } = await import("../services/playwright.ts");
525
- await closeAllPlaywright();
526
-
527
- const activeServer = server;
528
- server = undefined;
529
- if (activeServer?.close) {
530
- // Drain in-flight requests BEFORE flushing: a request finishing during the
531
- // drain can still call updateLogicalThreadState, and its debounced write
532
- // must land in SQLite while the DB is still open.
533
- await new Promise<void>((resolve) => {
534
- try {
535
- if (activeServer.close.length > 0) {
536
- activeServer.close(() => resolve());
537
- } else {
538
- activeServer.close();
539
- resolve();
540
- }
541
- } catch {
542
- resolve();
543
- }
544
- });
545
- }
546
-
547
- const { flushLogicalThreadState } = await import("../services/qwen.ts");
548
- try {
549
- // Debounced logical-thread upserts must land before the DB closes.
550
- flushLogicalThreadState();
551
- } catch {
552
- // Persistence is best-effort; the in-memory cache already served this run.
553
- }
554
-
555
- const { closeDatabase } = await import("../core/database.ts");
556
- closeDatabase();
557
- }
558
-
559
- async function handleSignal(signal: string): Promise<never> {
560
- console.log(
561
- `🛑 [Server] Shutdown | ${signal}`,
562
- );
563
- await stopServer();
564
- process.exit(0);
565
- }
566
-
567
- function installSignalHandlers(): void {
568
- if (signalHandlersInstalled) return;
569
- process.on("SIGINT", () => {
570
- void handleSignal("SIGINT");
571
- });
572
- process.on("SIGTERM", () => {
573
- void handleSignal("SIGTERM");
574
- });
575
- signalHandlersInstalled = true;
576
- }
577
-
578
- export async function stopServer(): Promise<void> {
579
- if (stopPromise) {
580
- await stopPromise;
581
- return;
582
- }
583
-
584
- stopPromise = (async () => {
585
- if (!server && !cache && !watchdog) return;
586
- await cleanupServerResources();
587
- })();
588
-
589
- try {
590
- await stopPromise;
591
- } finally {
592
- stopPromise = null;
593
- }
594
- }
595
-
596
- export async function startServer(options?: {
597
- installSignalHandlers?: boolean;
598
- }): Promise<StartedServerInfo> {
599
- if (server) {
600
- if (options?.installSignalHandlers !== false) installSignalHandlers();
601
- return buildStartedServerInfo();
602
- }
603
-
604
- if (startPromise) {
605
- return startPromise;
606
- }
607
-
608
- startPromise = (async () => {
609
- cache = new MemoryCache();
610
- await cache.connect();
611
-
612
- if (!config.apiKey && config.server.host === "0.0.0.0") {
613
- // API key status will be shown in startup banner
614
- }
615
-
616
- const { loadAccounts, getAccountCredentials } =
617
- await import("../core/accounts.ts");
618
- const accounts = loadAccounts();
619
-
620
- if (accounts.length === 0 && !isAuthMockEnabled()) {
621
- throw new Error(
622
- "❌ [Server] No Qwen accounts configured. Configure an account with `npm run login`, the QWEN_ACCOUNTS environment variable, or the accounts database before starting the server.",
623
- );
624
- }
625
-
626
- // Fail fast on a taken port (the most common startup crash) BEFORE the
627
- // slow account warmup — the previous behavior bound only after warmup and
628
- // then crashed with a raw Node stack trace minutes into startup.
629
- await assertPortAvailable();
630
-
631
- // Restore persisted cooldowns (e.g. daily quota windows) from the database
632
- // instead of wiping them on restart — retrying a still-rate-limited account
633
- // wastes a request and immediately re-trips the same limit. Expired
634
- // entries are dropped lazily by the cooldown lookup.
635
- const { syncCooldownsFromDb } =
636
- await import("../core/account-manager.ts");
637
- syncCooldownsFromDb(accounts);
638
-
639
- const { getAccountsByPriority } =
640
- await import("../core/account-priority.ts");
641
-
642
- const { disableNativeTools, warmQwenChatPool } =
643
- await import("../services/qwen.ts");
644
- const { initPlaywrightForAccount, isPlaywrightInitialized } =
645
- await import("../services/playwright.ts");
646
-
647
- const BATCH_SIZE = config.playwright.initBatchSize;
648
-
649
- if (accounts.length > 0) {
650
- let readyAccountId: string | null = null;
651
- const totalAccounts = accounts.length;
652
-
653
- // Warm accounts in priority order (recently successful accounts first),
654
- // skipping accounts still on cooldown, so the startup account matches
655
- // the one request routing will pick first.
656
- const warmOrder = getAccountsByPriority(accounts).filter(
657
- (account) => !getAccountCooldownInfo(account.id),
658
- );
659
-
660
- for (let i = 0; i < warmOrder.length; i++) {
661
- const ok = await prepareAccountRuntime(
662
- warmOrder[i],
663
- getAccountCredentials,
664
- initPlaywrightForAccount,
665
- disableNativeTools,
666
- warmQwenChatPool,
667
- );
668
- if (ok) {
669
- console.log(
670
- `✅ [Server] Account ready (${i + 1}/${totalAccounts}): ${maskEmail(warmOrder[i].email)}`,
671
- );
672
- readyAccountId = warmOrder[i].id;
673
- break;
674
- }
675
- }
676
-
677
- const remainingAccounts = accounts.filter(
678
- (account) => account.id !== readyAccountId,
679
- );
680
- if (readyAccountId === null) {
681
- console.warn(
682
- `⚠️ [Server] No account ready during startup; continuing in background`,
683
- );
684
- }
685
-
686
- if (config.playwright.prepareAllOnStartup || readyAccountId === null) {
687
- if (config.playwright.prepareAllOnStartup && remainingAccounts.length > 0) {
688
- console.log(
689
- `🪶 [Server] Preparing ${remainingAccounts.length} standby account(s) in background`,
690
- );
691
- }
692
- void prepareRemainingAccountsInBackground({
693
- accounts: remainingAccounts,
694
- batchSize: BATCH_SIZE,
695
- totalAccounts,
696
- getAccountCredentials,
697
- initPlaywrightForAccount,
698
- disableNativeTools,
699
- warmQwenChatPool,
700
- }).catch((error) => {
701
- console.warn(
702
- `❌ [Server] Background account preparation failed: ${getErrorMessage(error)}`,
703
- );
704
- });
705
- } else if (remainingAccounts.length > 0) {
706
- console.log(
707
- `🪶 [Server] ${remainingAccounts.length} standby account(s) will initialize on demand`,
708
- );
709
-
710
- // Validate standby accounts in background: check login, add to priority,
711
- // but keep browser closed until actually needed
712
- void (async () => {
713
- const { validateAccountLogin } = await import("../services/playwright.ts");
714
- const { ensureAccountInPriority } = await import("../core/account-priority.ts");
715
-
716
- let validated = 0;
717
- let failed = 0;
718
-
719
- for (const account of remainingAccounts) {
720
- try {
721
- // Add to priority list first (initial priority based on config order)
722
- ensureAccountInPriority(account.id);
723
-
724
- // Validate login in background
725
- const ok = await validateAccountLogin(
726
- account,
727
- config.playwright.headless,
728
- config.playwright.browser,
729
- );
730
-
731
- if (ok) {
732
- validated++;
733
- console.log(
734
- `✅ [Server] Standby account validated: ${maskEmail(account.email)}`,
735
- );
736
- } else {
737
- failed++;
738
- console.warn(
739
- `⚠️ [Server] Standby account login failed: ${maskEmail(account.email)}`,
740
- );
741
- }
742
- } catch (error) {
743
- failed++;
744
- console.warn(
745
- `⚠️ [Server] Standby account validation error: ${maskEmail(account.email)}: ${getErrorMessage(error)}`,
746
- );
747
- }
748
- }
749
-
750
- if (validated > 0 || failed > 0) {
751
- console.log(
752
- `🪶 [Server] Standby validation complete: ${validated} ok, ${failed} failed`,
753
- );
754
- }
755
- })().catch((error) => {
756
- console.warn(
757
- `❌ [Server] Background standby validation failed: ${getErrorMessage(error)}`,
758
- );
759
- });
760
- }
761
- }
762
-
763
- watchdog = new Watchdog();
764
- watchdog.start();
765
-
766
- metrics.startCollection();
767
-
768
- const { startSessionKeeper } =
769
- await import("../services/session-keeper.ts");
770
- startSessionKeeper();
771
-
772
- const { startLeaseSweepTimer } =
773
- await import("../core/account-concurrency.ts");
774
- startLeaseSweepTimer();
775
-
776
- const serverInstance = serve({
777
- fetch: app.fetch,
778
- port: config.server.port,
779
- hostname: config.server.host,
780
- });
781
- // Node's http.Server emits 'error' (EADDRINUSE and friends) asynchronously,
782
- // AFTER serve() returns — with no listener the process crashes with a raw
783
- // stack trace. The pre-flight check above catches the common case before
784
- // warmup; this listener is the safety net for the rare race where the port
785
- // is taken between the check and the bind.
786
- serverInstance.on("error", (err: Error) => {
787
- const code = (err as NodeJS.ErrnoException).code;
788
- if (code === "EADDRINUSE") {
789
- console.error(
790
- buildPortInUseMessage(config.server.port, config.server.host),
791
- );
792
- } else {
793
- console.error(`❌ [Server] Listen failed: ${err.message}`);
794
- }
795
- process.exit(1);
796
- });
797
- server = serverInstance;
798
-
799
- if (options?.installSignalHandlers !== false) {
800
- installSignalHandlers();
801
- }
802
-
803
- const started = buildStartedServerInfo();
804
- const accountCount = accounts.length;
805
- const warmCount = accounts.filter((account) =>
806
- isPlaywrightInitialized(account.id),
807
- ).length;
808
-
809
- // API key display: just show if it's set or not
810
- const apiKey = process.env.API_KEY || config.apiKey;
811
- const apiKeyDisplay = apiKey ? "Set" : "Not set";
812
-
813
- // Use only fixed-width chars (ASCII + ●) to guarantee perfect alignment
814
- // across all terminals (emojis vary between 1-2 cell widths unpredictably)
815
- const W = 58; // inner width (60 minus 2 border chars)
816
- const center = (text: string): string => {
817
- const padLeft = Math.floor((W - text.length) / 2);
818
- const padRight = W - text.length - padLeft;
819
- return " ".repeat(padLeft) + text + " ".repeat(padRight);
820
- };
821
- const blank = () => " ".repeat(W);
822
- const row = (label: string, value: string): string => {
823
- const labelCol = (label + " ".repeat(Math.max(0, 12 - label.length)));
824
- const valCol = value + " ".repeat(Math.max(0, W - 14 - value.length));
825
- return " " + labelCol + valCol;
826
- };
827
-
828
- const endpoint = `${started.url}/v1`;
829
-
830
- console.log(`
831
- +${"-".repeat(W)}+
832
- |${blank()}|
833
- |${center("QwenProxy")}|
834
- |${center("OpenAI & Anthropic Compatible API")}|
835
- |${blank()}|
836
- +${"-".repeat(W)}+
837
- |${blank()}|
838
- |${row("Endpoint", endpoint)}|
839
- |${row("Port", String(started.port))}|
840
- |${row("Accounts", `${warmCount}/${accountCount} warm`)}|
841
- |${row("API Key", apiKeyDisplay)}|
842
- |${row("Status", "● Online")}|
843
- |${blank()}|
844
- +${"-".repeat(W)}+
845
- `);
846
- return started;
847
- })();
848
-
849
- try {
850
- return await startPromise;
851
- } catch (error) {
852
- await cleanupServerResources().catch(() => {});
853
- throw error;
854
- } finally {
855
- startPromise = null;
856
- }
857
- }
858
-
859
- export { app };
1
+ import crypto from "crypto";
2
+ import net from "node:net";
3
+ import { v4 as uuidv4 } from "uuid";
4
+ import { Hono, type Context } from "hono";
5
+ import { serve } from "@hono/node-server";
6
+ import { config } from "../core/config.js";
7
+ import { metrics } from "../core/metrics.js";
8
+ import { logger, maskEmail } from "../core/logger.js";
9
+ import { MemoryCache } from "../cache/memory-cache.js";
10
+ import { Watchdog } from "../core/watchdog.js";
11
+ import { getAccountCooldownInfo } from "../core/account-manager.js";
12
+ import { app as modelsApp } from "./models.js";
13
+ import { chatCompletions, chatCompletionsStop } from "../routes/chat.js";
14
+ import { uploadFile } from "../routes/upload.js";
15
+ import { imagesGenerations } from "../routes/images.js";
16
+ import { videosGenerations, videoTaskStatus } from "../routes/videos.js";
17
+ import { responsesApp } from "../routes/responses/index.js";
18
+ import { completionsLegacy } from "../routes/completions.js";
19
+ import { anthropicApp } from "../routes/anthropic/index.ts";
20
+ import { sendOpenAIError } from "./error-helpers.js";
21
+ import { AuthError, NotFoundError } from "../core/errors.js";
22
+ import type { QwenAccount } from "../core/accounts.js";
23
+ import { isAuthMockEnabled } from "../services/auth-playwright.js";
24
+
25
+ // Module-level state (initialized in startServer)
26
+ let cache: MemoryCache | undefined;
27
+ let watchdog: Watchdog | undefined;
28
+ let server: any;
29
+ let startPromise: Promise<StartedServerInfo> | null = null;
30
+ let stopPromise: Promise<void> | null = null;
31
+ let signalHandlersInstalled = false;
32
+
33
+ const app = new Hono();
34
+
35
+ function formatAccountId(accountId: string): string {
36
+ const normalized = accountId.trim();
37
+ return normalized.length > 12 ? `${normalized.slice(0, 12)}…` : normalized;
38
+ }
39
+
40
+ function buildPortInUseMessage(port: number, host: string): string {
41
+ return (
42
+ `❌ [Server] Port ${port} is already in use (${host}:${port}).` +
43
+ `\n Another QwenProxy instance (or another program) is listening on this port.` +
44
+ `\n Stop the other instance first, or start on another port: PORT=3001 npm start`
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Pre-flight port check run BEFORE the slow account warmup so a conflicting
50
+ * listener fails in <1s with an explanatory message instead of crashing the
51
+ * process minutes later after the warmup completes.
52
+ */
53
+ async function assertPortAvailable(): Promise<void> {
54
+ const { port, host } = config.server;
55
+ await new Promise<void>((resolve, reject) => {
56
+ const probe = net.createServer();
57
+ probe.once("error", (err: Error) => {
58
+ const code = (err as NodeJS.ErrnoException).code;
59
+ if (code === "EADDRINUSE") {
60
+ reject(new Error(buildPortInUseMessage(port, host)));
61
+ } else {
62
+ reject(err);
63
+ }
64
+ });
65
+ probe.once("listening", () => probe.close(() => resolve()));
66
+ probe.listen(port, host);
67
+ });
68
+ }
69
+
70
+ export function setCacheForTesting(nextCache: MemoryCache | undefined): void {
71
+ cache = nextCache;
72
+ }
73
+
74
+ // Middleware must be registered BEFORE routes
75
+
76
+ // CORS: browser-based clients (OpenWebUI, web frontends on another origin)
77
+ // preflight before the Authorization header is sent, so OPTIONS short-circuits
78
+ // BEFORE the /v1/* auth middleware. Default is permissive (doc checklist item
79
+ // 2); set CORS_ORIGIN to lock it down.
80
+ const corsOrigin = process.env.CORS_ORIGIN || "*";
81
+ app.use("*", async (c, next) => {
82
+ c.header("Access-Control-Allow-Origin", corsOrigin);
83
+ c.header(
84
+ "Access-Control-Allow-Methods",
85
+ "GET, POST, PUT, PATCH, DELETE, OPTIONS",
86
+ );
87
+ c.header(
88
+ "Access-Control-Allow-Headers",
89
+ "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id",
90
+ );
91
+ c.header(
92
+ "Access-Control-Expose-Headers",
93
+ "X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
94
+ );
95
+ if (c.req.method === "OPTIONS") {
96
+ // Hono does not merge c.header() values into a manually constructed
97
+ // Response, so the preflight carries its CORS headers explicitly.
98
+ return new Response(null, {
99
+ status: 204,
100
+ headers: {
101
+ "Access-Control-Allow-Origin": corsOrigin,
102
+ "Access-Control-Allow-Methods":
103
+ "GET, POST, PUT, PATCH, DELETE, OPTIONS",
104
+ "Access-Control-Allow-Headers":
105
+ "Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id",
106
+ "Access-Control-Expose-Headers":
107
+ "X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
108
+ },
109
+ });
110
+ }
111
+ await next();
112
+ });
113
+
114
+ app.use("*", async (c, next) => {
115
+ const requestId = c.req.header("X-Request-Id") || uuidv4();
116
+ c.header("X-Request-Id", requestId);
117
+
118
+ // OpenAI-shaped response headers (doc §5.2): API version, processing time,
119
+ // and static rate-limit windows so tools that parse x-ratelimit-* don't choke.
120
+ c.header("openai-version", "2020-10-01");
121
+ const ratelimit = config.server.rateLimit;
122
+ c.header("x-ratelimit-limit-requests", String(ratelimit.requests));
123
+ c.header(
124
+ "x-ratelimit-remaining-requests",
125
+ String(Math.max(0, ratelimit.requests - 1)),
126
+ );
127
+ c.header("x-ratelimit-reset-requests", "0");
128
+ c.header("x-ratelimit-limit-tokens", String(ratelimit.tokens));
129
+ c.header(
130
+ "x-ratelimit-remaining-tokens",
131
+ String(Math.max(0, ratelimit.tokens - 1)),
132
+ );
133
+ c.header("x-ratelimit-reset-tokens", "0");
134
+
135
+ metrics.increment("requests.total");
136
+ const start = Date.now();
137
+ await next();
138
+ const duration = Date.now() - start;
139
+ metrics.histogram("latency.request", duration);
140
+ c.header("X-Response-Time", `${duration}ms`);
141
+ c.header("openai-processing-ms", String(duration));
142
+ });
143
+
144
+ function constantTimeStringEqual(provided: string, expected: string): boolean {
145
+ const providedBuf = Buffer.from(provided);
146
+ const expectedBuf = Buffer.from(expected);
147
+ const providedHash = crypto.createHash("sha256").update(providedBuf).digest();
148
+ const expectedHash = crypto.createHash("sha256").update(expectedBuf).digest();
149
+
150
+ return (
151
+ crypto.timingSafeEqual(providedHash, expectedHash) &&
152
+ providedBuf.length === expectedBuf.length
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Accept OpenAI-style Bearer and x-api-key.
158
+ * Either may authenticate when API_KEY is configured.
159
+ */
160
+ function extractProvidedApiKeys(c: Context): string[] {
161
+ const keys: string[] = [];
162
+ const auth = c.req.header("Authorization");
163
+ if (auth?.startsWith("Bearer ")) {
164
+ const token = auth.slice(7).trim();
165
+ if (token) keys.push(token);
166
+ }
167
+ const xApiKey = c.req.header("x-api-key")?.trim();
168
+ if (xApiKey) keys.push(xApiKey);
169
+ return keys;
170
+ }
171
+
172
+ function verifyApiKey(c: Context): Response | null {
173
+ const apiKey = process.env.API_KEY || config.apiKey;
174
+ if (!apiKey) return null;
175
+
176
+ const candidates = extractProvidedApiKeys(c);
177
+ const isAnthropic =
178
+ c.req.path.startsWith("/v1/messages") ||
179
+ !!c.req.header("anthropic-version");
180
+
181
+ if (candidates.length === 0) {
182
+ if (isAnthropic) {
183
+ c.header("anthropic-version", c.req.header("anthropic-version") || "2023-06-01");
184
+ return c.json(
185
+ {
186
+ type: "error",
187
+ error: {
188
+ type: "authentication_error",
189
+ message: "Missing or invalid credentials (Authorization Bearer or x-api-key)",
190
+ },
191
+ },
192
+ 401,
193
+ );
194
+ }
195
+ return sendOpenAIError(
196
+ c,
197
+ new AuthError(
198
+ "Missing or invalid credentials (Authorization Bearer or x-api-key)",
199
+ ),
200
+ );
201
+ }
202
+ if (candidates.some((token) => constantTimeStringEqual(token, apiKey))) {
203
+ return null;
204
+ }
205
+ if (isAnthropic) {
206
+ c.header("anthropic-version", c.req.header("anthropic-version") || "2023-06-01");
207
+ return c.json(
208
+ {
209
+ type: "error",
210
+ error: {
211
+ type: "authentication_error",
212
+ message: "Invalid API key",
213
+ },
214
+ },
215
+ 401,
216
+ );
217
+ }
218
+ return sendOpenAIError(c, new AuthError("Invalid API key"));
219
+ }
220
+ app.use("/v1/*", async (c, next) => {
221
+ const error = verifyApiKey(c);
222
+ if (error) return error;
223
+ await next();
224
+ });
225
+
226
+ // Routes
227
+ app.route("", modelsApp);
228
+ app.post("/v1/chat/completions", chatCompletions);
229
+ app.post("/v1/chat/completions/stop", chatCompletionsStop);
230
+ app.post("/v1/completions", completionsLegacy);
231
+ app.post("/v1/upload", uploadFile);
232
+ app.post("/v1/images/generations", imagesGenerations);
233
+ app.post("/v1/videos/generations", videosGenerations);
234
+ app.get("/v1/tasks/status/:taskId", videoTaskStatus);
235
+
236
+ // OpenAI Responses API compatible routes
237
+ app.route("", responsesApp);
238
+ app.route("", anthropicApp);
239
+
240
+ // Accept paths without the /v1 prefix via a 308 redirect (method + body are
241
+ // preserved on redirect). Most clients append /v1 themselves; the redirect
242
+ // covers the rest without duplicating handlers.
243
+ const LEGACY_REDIRECTS: Array<[string, string]> = [
244
+ ["/chat/completions", "/v1/chat/completions"],
245
+ ["/completions", "/v1/completions"],
246
+ ["/responses", "/v1/responses"],
247
+ ["/models", "/v1/models"],
248
+ ["/messages", "/v1/messages"],
249
+ ["/messages/count_tokens", "/v1/messages/count_tokens"],
250
+ ];
251
+ for (const [from, to] of LEGACY_REDIRECTS) {
252
+ app.all(from, (c) => c.redirect(to, 308));
253
+ }
254
+
255
+ app.get("/health", async (c) => {
256
+ const status = await watchdog?.getStatus();
257
+ return c.json({
258
+ status: status?.overall || "unknown",
259
+ ram: status?.ram || "unknown",
260
+ streams: status?.streams || "unknown",
261
+ heap: status?.heap
262
+ ? {
263
+ used: status.heap.heapUsed,
264
+ total: status.heap.heapTotal,
265
+ limit: status.heap.heapSizeLimit,
266
+ rss: status.heap.rss,
267
+ usagePercent: Number(status.heap.usagePercent.toFixed(2)),
268
+ }
269
+ : undefined,
270
+ timestamp: Date.now(),
271
+ readyAccounts: (await import("../core/account-manager.js")).getHeadersReadyAccountIds(),
272
+ metrics: {
273
+ cache: await cache?.getStats(),
274
+ },
275
+ });
276
+ });
277
+
278
+ // Token TTL diagnostics: inspect real cookie/header lifetimes
279
+ app.get("/diagnostics/tokens", async (c) => {
280
+ const error = verifyApiKey(c);
281
+ if (error) return error;
282
+
283
+ const { getTokenDiagnostics } = await import("../services/playwright.ts");
284
+ const accountId = c.req.query("accountId");
285
+
286
+ try {
287
+ const diagnostics = await getTokenDiagnostics(accountId);
288
+ return c.json(diagnostics);
289
+ } catch (err) {
290
+ return c.json(
291
+ {
292
+ error: err instanceof Error ? err.message : String(err),
293
+ },
294
+ 500,
295
+ );
296
+ }
297
+ });
298
+
299
+ app.get("/metrics", (c) => {
300
+ const error = verifyApiKey(c);
301
+ if (error) return error;
302
+ return c.text(metrics.formatPrometheus(), {
303
+ headers: { "Content-Type": "text/plain; version=0.0.4" },
304
+ });
305
+ });
306
+
307
+ app.onError((err, c) => {
308
+ const requestId = c.req.header("X-Request-Id") || "unknown";
309
+ metrics.increment("requests.errors");
310
+ logger.error("API Error", {
311
+ requestId,
312
+ error: err instanceof Error ? err.message : String(err),
313
+ stack: err instanceof Error ? err.stack : undefined,
314
+ });
315
+ return sendOpenAIError(c, err);
316
+ });
317
+
318
+ app.notFound((c) => sendOpenAIError(c, new NotFoundError("Not found")));
319
+
320
+ export interface StartedServerInfo {
321
+ host: string;
322
+ port: number;
323
+ url: string;
324
+ }
325
+
326
+ function buildStartedServerInfo(): StartedServerInfo {
327
+ const host =
328
+ config.server.host === "0.0.0.0" ? "127.0.0.1" : config.server.host;
329
+ return {
330
+ host,
331
+ port: config.server.port,
332
+ url: `http://${host}:${config.server.port}`,
333
+ };
334
+ }
335
+
336
+ function getErrorMessage(error: unknown): string {
337
+ return error instanceof Error ? error.message : String(error);
338
+ }
339
+
340
+ async function warmConfiguredChatPools(
341
+ warmQwenChatPool: (
342
+ accountId: string | undefined,
343
+ modelId: string,
344
+ ) => Promise<void>,
345
+ accountId?: string,
346
+ ): Promise<void> {
347
+ await Promise.all(
348
+ config.qwen.chatPoolModels.map((model) =>
349
+ warmQwenChatPool(accountId, model).catch(() => {}),
350
+ ),
351
+ );
352
+ }
353
+
354
+ async function prepareQwenRuntime(params: {
355
+ accountId?: string;
356
+ successMessage: string;
357
+ failureMessage: string;
358
+ initAuth: () => Promise<void>;
359
+ disableNativeTools: (accountId?: string) => Promise<void>;
360
+ warmQwenChatPool: (
361
+ accountId: string | undefined,
362
+ modelId: string,
363
+ ) => Promise<void>;
364
+ }): Promise<boolean> {
365
+ if (params.accountId) {
366
+ const { getAccountCooldownInfo } =
367
+ await import("../core/account-manager.ts");
368
+ const cooldownInfo = getAccountCooldownInfo(params.accountId);
369
+ if (cooldownInfo) {
370
+ console.warn(
371
+ `⚠️ [Server] Account not ready | account=${formatAccountId(params.accountId)} | cooldown=${Math.ceil(cooldownInfo.remainingMs / 1000)}s | reason=${cooldownInfo.reason}`,
372
+ );
373
+ return false;
374
+ }
375
+ }
376
+
377
+ try {
378
+ await params.initAuth();
379
+ await params.disableNativeTools(params.accountId).catch(() => {});
380
+ await warmConfiguredChatPools(params.warmQwenChatPool, params.accountId);
381
+ if (params.accountId) {
382
+ const { getAccountCooldownInfo } =
383
+ await import("../core/account-manager.ts");
384
+ const cooldownInfo = getAccountCooldownInfo(params.accountId);
385
+ if (cooldownInfo) {
386
+ console.warn(
387
+ `⚠️ [Server] Account not ready | account=${formatAccountId(params.accountId)} | cooldown=${Math.ceil(cooldownInfo.remainingMs / 1000)}s | reason=${cooldownInfo.reason}`,
388
+ );
389
+ return false;
390
+ }
391
+ }
392
+ return true;
393
+ } catch (error) {
394
+ console.warn(`❌ ${params.failureMessage}`, getErrorMessage(error));
395
+ if (params.accountId) {
396
+ const { markAccountRateLimited } =
397
+ await import("../core/account-manager.ts");
398
+ markAccountRateLimited(
399
+ params.accountId,
400
+ config.concurrency.initFailureCooldownMs,
401
+ "AuthInitFailed",
402
+ );
403
+ }
404
+ return false;
405
+ }
406
+ }
407
+
408
+ async function prepareAccountRuntime(
409
+ account: QwenAccount,
410
+ getAccountCredentials: (accountId: string) => QwenAccount | undefined,
411
+ initPlaywrightForAccount: (
412
+ account: QwenAccount,
413
+ headless: boolean,
414
+ browserType?: "chromium" | "chrome" | "edge",
415
+ ) => Promise<void>,
416
+ disableNativeTools: (accountId?: string) => Promise<void>,
417
+ warmQwenChatPool: (
418
+ accountId: string | undefined,
419
+ modelId: string,
420
+ ) => Promise<void>,
421
+ ): Promise<boolean> {
422
+ return prepareQwenRuntime({
423
+ accountId: account.id,
424
+ successMessage: `[Server] Account ready: ${maskEmail(account.email)}`,
425
+ failureMessage: `[Server] Account init failed ${maskEmail(account.email)}:`,
426
+ initAuth: () => {
427
+ const credentials = getAccountCredentials(account.id);
428
+ if (!credentials) {
429
+ throw new Error(`Account ${account.id} credentials not found`);
430
+ }
431
+ return initPlaywrightForAccount(
432
+ credentials,
433
+ config.playwright.headless,
434
+ config.playwright.browser,
435
+ );
436
+ },
437
+ disableNativeTools,
438
+ warmQwenChatPool,
439
+ });
440
+ }
441
+
442
+ async function prepareRemainingAccountsInBackground(params: {
443
+ accounts: QwenAccount[];
444
+ batchSize: number;
445
+ totalAccounts: number;
446
+ getAccountCredentials: (accountId: string) => QwenAccount | undefined;
447
+ initPlaywrightForAccount: (
448
+ account: QwenAccount,
449
+ headless: boolean,
450
+ browserType?: "chromium" | "chrome" | "edge",
451
+ ) => Promise<void>;
452
+ disableNativeTools: (accountId?: string) => Promise<void>;
453
+ warmQwenChatPool: (
454
+ accountId: string | undefined,
455
+ modelId: string,
456
+ ) => Promise<void>;
457
+ }): Promise<void> {
458
+ const remaining = params.accounts;
459
+ if (remaining.length === 0) return;
460
+
461
+ // First account was already prepared successfully (displayed as 1/N),
462
+ // so remaining accounts start at display index 2.
463
+ let nextDisplayIndex = 2;
464
+ for (let i = 0; i < remaining.length; i += params.batchSize) {
465
+ const batch = remaining.slice(i, i + params.batchSize);
466
+ const batchDisplayStart = nextDisplayIndex;
467
+ nextDisplayIndex += batch.length;
468
+
469
+ await Promise.all(
470
+ batch.map((account, batchIndex) =>
471
+ prepareAccountRuntime(
472
+ account,
473
+ params.getAccountCredentials,
474
+ params.initPlaywrightForAccount,
475
+ params.disableNativeTools,
476
+ params.warmQwenChatPool,
477
+ ).then((ok) => {
478
+ if (ok) {
479
+ const displayIndex = batchDisplayStart + batchIndex;
480
+ console.log(
481
+ `✅ [Server] Account ready (${displayIndex}/${params.totalAccounts}): ${maskEmail(account.email)}`,
482
+ );
483
+ }
484
+ return ok;
485
+ }),
486
+ ),
487
+ );
488
+ }
489
+ }
490
+
491
+ async function cleanupServerResources(): Promise<void> {
492
+ watchdog?.stop();
493
+ watchdog = undefined;
494
+ metrics.stopCollection();
495
+
496
+ try {
497
+ await cache?.close();
498
+ } finally {
499
+ cache = undefined;
500
+ }
501
+
502
+ try {
503
+ const { stopSessionKeeper } = await import("../services/session-keeper.ts");
504
+ stopSessionKeeper();
505
+ } catch {
506
+ // Session keeper may not have been initialized.
507
+ }
508
+
509
+ if (config.qwen.deleteAllChatsOnShutdown) {
510
+ try {
511
+ const { deleteChatsForConfiguredAccounts } =
512
+ await import("../services/chat-cleanup.ts");
513
+ const result = await deleteChatsForConfiguredAccounts();
514
+ console.log(
515
+ `🗑️ [Server] Deleted Qwen chats on shutdown: ${result.succeeded}/${result.attempted} scope(s)`,
516
+ );
517
+ } catch (error) {
518
+ console.error(
519
+ `❌ [Server] Failed to delete Qwen chats on shutdown:`,
520
+ error instanceof Error ? error.message : String(error),
521
+ );
522
+ }
523
+ }
524
+
525
+ const { closeAllPlaywright } = await import("../services/playwright.ts");
526
+ await closeAllPlaywright();
527
+
528
+ const activeServer = server;
529
+ server = undefined;
530
+ if (activeServer?.close) {
531
+ // Drain in-flight requests BEFORE flushing: a request finishing during the
532
+ // drain can still call updateLogicalThreadState, and its debounced write
533
+ // must land in SQLite while the DB is still open.
534
+ await new Promise<void>((resolve) => {
535
+ try {
536
+ if (activeServer.close.length > 0) {
537
+ activeServer.close(() => resolve());
538
+ } else {
539
+ activeServer.close();
540
+ resolve();
541
+ }
542
+ } catch {
543
+ resolve();
544
+ }
545
+ });
546
+ }
547
+
548
+ const { flushLogicalThreadState } = await import("../services/qwen.ts");
549
+ try {
550
+ // Debounced logical-thread upserts must land before the DB closes.
551
+ flushLogicalThreadState();
552
+ } catch {
553
+ // Persistence is best-effort; the in-memory cache already served this run.
554
+ }
555
+
556
+ const { closeDatabase } = await import("../core/database.ts");
557
+ closeDatabase();
558
+ }
559
+
560
+ async function handleSignal(signal: string): Promise<never> {
561
+ console.log(
562
+ `🛑 [Server] Shutdown | ${signal}`,
563
+ );
564
+ await stopServer();
565
+ process.exit(0);
566
+ }
567
+
568
+ function installSignalHandlers(): void {
569
+ if (signalHandlersInstalled) return;
570
+ process.on("SIGINT", () => {
571
+ void handleSignal("SIGINT");
572
+ });
573
+ process.on("SIGTERM", () => {
574
+ void handleSignal("SIGTERM");
575
+ });
576
+ signalHandlersInstalled = true;
577
+ }
578
+
579
+ export async function stopServer(): Promise<void> {
580
+ if (stopPromise) {
581
+ await stopPromise;
582
+ return;
583
+ }
584
+
585
+ stopPromise = (async () => {
586
+ if (!server && !cache && !watchdog) return;
587
+ await cleanupServerResources();
588
+ })();
589
+
590
+ try {
591
+ await stopPromise;
592
+ } finally {
593
+ stopPromise = null;
594
+ }
595
+ }
596
+
597
+ export async function startServer(options?: {
598
+ installSignalHandlers?: boolean;
599
+ }): Promise<StartedServerInfo> {
600
+ if (server) {
601
+ if (options?.installSignalHandlers !== false) installSignalHandlers();
602
+ return buildStartedServerInfo();
603
+ }
604
+
605
+ if (startPromise) {
606
+ return startPromise;
607
+ }
608
+
609
+ startPromise = (async () => {
610
+ cache = new MemoryCache();
611
+ await cache.connect();
612
+
613
+ if (!config.apiKey && config.server.host === "0.0.0.0") {
614
+ // API key status will be shown in startup banner
615
+ }
616
+
617
+ const { loadAccounts, getAccountCredentials } =
618
+ await import("../core/accounts.ts");
619
+ const accounts = loadAccounts();
620
+
621
+ if (accounts.length === 0 && !isAuthMockEnabled()) {
622
+ throw new Error(
623
+ "❌ [Server] No Qwen accounts configured. Configure an account with `npm run login`, the QWEN_ACCOUNTS environment variable, or the accounts database before starting the server.",
624
+ );
625
+ }
626
+
627
+ // Fail fast on a taken port (the most common startup crash) BEFORE the
628
+ // slow account warmup — the previous behavior bound only after warmup and
629
+ // then crashed with a raw Node stack trace minutes into startup.
630
+ await assertPortAvailable();
631
+
632
+ // Restore persisted cooldowns (e.g. daily quota windows) from the database
633
+ // instead of wiping them on restart — retrying a still-rate-limited account
634
+ // wastes a request and immediately re-trips the same limit. Expired
635
+ // entries are dropped lazily by the cooldown lookup.
636
+ const { syncCooldownsFromDb } =
637
+ await import("../core/account-manager.ts");
638
+ syncCooldownsFromDb(accounts);
639
+
640
+ const { getAccountsByPriority } =
641
+ await import("../core/account-priority.ts");
642
+
643
+ const { disableNativeTools, warmQwenChatPool } =
644
+ await import("../services/qwen.ts");
645
+ const { initPlaywrightForAccount, isPlaywrightInitialized } =
646
+ await import("../services/playwright.ts");
647
+
648
+ const BATCH_SIZE = config.playwright.initBatchSize;
649
+
650
+ if (accounts.length > 0) {
651
+ let readyAccountId: string | null = null;
652
+ const totalAccounts = accounts.length;
653
+
654
+ // Warm accounts in priority order (recently successful accounts first),
655
+ // skipping accounts still on cooldown, so the startup account matches
656
+ // the one request routing will pick first.
657
+ const warmOrder = getAccountsByPriority(accounts).filter(
658
+ (account) => !getAccountCooldownInfo(account.id),
659
+ );
660
+
661
+ for (let i = 0; i < warmOrder.length; i++) {
662
+ const ok = await prepareAccountRuntime(
663
+ warmOrder[i],
664
+ getAccountCredentials,
665
+ initPlaywrightForAccount,
666
+ disableNativeTools,
667
+ warmQwenChatPool,
668
+ );
669
+ if (ok) {
670
+ console.log(
671
+ `✅ [Server] Account ready (${i + 1}/${totalAccounts}): ${maskEmail(warmOrder[i].email)}`,
672
+ );
673
+ readyAccountId = warmOrder[i].id;
674
+ break;
675
+ }
676
+ }
677
+
678
+ const remainingAccounts = accounts.filter(
679
+ (account) => account.id !== readyAccountId,
680
+ );
681
+ if (readyAccountId === null) {
682
+ console.warn(
683
+ `⚠️ [Server] No account ready during startup; continuing in background`,
684
+ );
685
+ }
686
+
687
+ if (config.playwright.prepareAllOnStartup || readyAccountId === null) {
688
+ if (config.playwright.prepareAllOnStartup && remainingAccounts.length > 0) {
689
+ console.log(
690
+ `🪶 [Server] Preparing ${remainingAccounts.length} standby account(s) in background`,
691
+ );
692
+ }
693
+ void prepareRemainingAccountsInBackground({
694
+ accounts: remainingAccounts,
695
+ batchSize: BATCH_SIZE,
696
+ totalAccounts,
697
+ getAccountCredentials,
698
+ initPlaywrightForAccount,
699
+ disableNativeTools,
700
+ warmQwenChatPool,
701
+ }).catch((error) => {
702
+ console.warn(
703
+ `❌ [Server] Background account preparation failed: ${getErrorMessage(error)}`,
704
+ );
705
+ });
706
+ } else if (remainingAccounts.length > 0) {
707
+ console.log(
708
+ `🪶 [Server] ${remainingAccounts.length} standby account(s) will initialize on demand`,
709
+ );
710
+
711
+ // Validate standby accounts in background: check login, add to priority,
712
+ // but keep browser closed until actually needed
713
+ void (async () => {
714
+ const { validateAccountLogin } = await import("../services/playwright.ts");
715
+ const { ensureAccountInPriority } = await import("../core/account-priority.ts");
716
+
717
+ let validated = 0;
718
+ let failed = 0;
719
+
720
+ for (const account of remainingAccounts) {
721
+ try {
722
+ // Add to priority list first (initial priority based on config order)
723
+ ensureAccountInPriority(account.id);
724
+
725
+ // Validate login in background
726
+ const ok = await validateAccountLogin(
727
+ account,
728
+ config.playwright.headless,
729
+ config.playwright.browser,
730
+ );
731
+
732
+ if (ok) {
733
+ validated++;
734
+ console.log(
735
+ `✅ [Server] Standby account validated: ${maskEmail(account.email)}`,
736
+ );
737
+ } else {
738
+ failed++;
739
+ console.warn(
740
+ `⚠️ [Server] Standby account login failed: ${maskEmail(account.email)}`,
741
+ );
742
+ }
743
+ } catch (error) {
744
+ failed++;
745
+ console.warn(
746
+ `⚠️ [Server] Standby account validation error: ${maskEmail(account.email)}: ${getErrorMessage(error)}`,
747
+ );
748
+ }
749
+ }
750
+
751
+ if (validated > 0 || failed > 0) {
752
+ console.log(
753
+ `🪶 [Server] Standby validation complete: ${validated} ok, ${failed} failed`,
754
+ );
755
+ }
756
+ })().catch((error) => {
757
+ console.warn(
758
+ `❌ [Server] Background standby validation failed: ${getErrorMessage(error)}`,
759
+ );
760
+ });
761
+ }
762
+ }
763
+
764
+ watchdog = new Watchdog();
765
+ watchdog.start();
766
+
767
+ metrics.startCollection();
768
+
769
+ const { startSessionKeeper } =
770
+ await import("../services/session-keeper.ts");
771
+ startSessionKeeper();
772
+
773
+ const { startLeaseSweepTimer } =
774
+ await import("../core/account-concurrency.ts");
775
+ startLeaseSweepTimer();
776
+
777
+ const serverInstance = serve({
778
+ fetch: app.fetch,
779
+ port: config.server.port,
780
+ hostname: config.server.host,
781
+ });
782
+ // Node's http.Server emits 'error' (EADDRINUSE and friends) asynchronously,
783
+ // AFTER serve() returns — with no listener the process crashes with a raw
784
+ // stack trace. The pre-flight check above catches the common case before
785
+ // warmup; this listener is the safety net for the rare race where the port
786
+ // is taken between the check and the bind.
787
+ serverInstance.on("error", (err: Error) => {
788
+ const code = (err as NodeJS.ErrnoException).code;
789
+ if (code === "EADDRINUSE") {
790
+ console.error(
791
+ buildPortInUseMessage(config.server.port, config.server.host),
792
+ );
793
+ } else {
794
+ console.error(`❌ [Server] Listen failed: ${err.message}`);
795
+ }
796
+ process.exit(1);
797
+ });
798
+ server = serverInstance;
799
+
800
+ if (options?.installSignalHandlers !== false) {
801
+ installSignalHandlers();
802
+ }
803
+
804
+ const started = buildStartedServerInfo();
805
+ const accountCount = accounts.length;
806
+ const warmCount = accounts.filter((account) =>
807
+ isPlaywrightInitialized(account.id),
808
+ ).length;
809
+
810
+ // API key display: just show if it's set or not
811
+ const apiKey = process.env.API_KEY || config.apiKey;
812
+ const apiKeyDisplay = apiKey ? "Set" : "Not set";
813
+
814
+ // Use only fixed-width chars (ASCII + ●) to guarantee perfect alignment
815
+ // across all terminals (emojis vary between 1-2 cell widths unpredictably)
816
+ const W = 58; // inner width (60 minus 2 border chars)
817
+ const center = (text: string): string => {
818
+ const padLeft = Math.floor((W - text.length) / 2);
819
+ const padRight = W - text.length - padLeft;
820
+ return " ".repeat(padLeft) + text + " ".repeat(padRight);
821
+ };
822
+ const blank = () => " ".repeat(W);
823
+ const row = (label: string, value: string): string => {
824
+ const labelCol = (label + " ".repeat(Math.max(0, 12 - label.length)));
825
+ const valCol = value + " ".repeat(Math.max(0, W - 14 - value.length));
826
+ return " " + labelCol + valCol;
827
+ };
828
+
829
+ const endpoint = `${started.url}/v1`;
830
+
831
+ console.log(`
832
+ +${"-".repeat(W)}+
833
+ |${blank()}|
834
+ |${center("QwenProxy")}|
835
+ |${center("OpenAI & Anthropic Compatible API")}|
836
+ |${blank()}|
837
+ +${"-".repeat(W)}+
838
+ |${blank()}|
839
+ |${row("Endpoint", endpoint)}|
840
+ |${row("Port", String(started.port))}|
841
+ |${row("Accounts", `${warmCount}/${accountCount} warm`)}|
842
+ |${row("API Key", apiKeyDisplay)}|
843
+ |${row("Status", "● Online")}|
844
+ |${blank()}|
845
+ +${"-".repeat(W)}+
846
+ `);
847
+ return started;
848
+ })();
849
+
850
+ try {
851
+ return await startPromise;
852
+ } catch (error) {
853
+ await cleanupServerResources().catch(() => {});
854
+ throw error;
855
+ } finally {
856
+ startPromise = null;
857
+ }
858
+ }
859
+
860
+ export { app };