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.
- package/LICENSE +14 -14
- package/README.md +906 -906
- package/bin/qwenproxy.js +5 -1
- package/package.json +77 -78
- package/src/api/error-classifier.ts +159 -159
- package/src/api/error-helpers.ts +118 -118
- package/src/api/models.ts +261 -261
- package/src/api/server.ts +860 -859
- package/src/cache/memory-cache.ts +385 -385
- package/src/clean-cache.ts +204 -204
- package/src/core/account-concurrency.ts +671 -671
- package/src/core/account-manager.ts +301 -297
- package/src/core/account-priority.ts +163 -163
- package/src/core/accounts.ts +186 -186
- package/src/core/config.ts +383 -383
- package/src/core/crypto-utils.ts +79 -79
- package/src/core/database.ts +276 -276
- package/src/core/errors.ts +118 -118
- package/src/core/logger.ts +269 -269
- package/src/core/memory-usage.ts +84 -84
- package/src/core/metrics.ts +291 -291
- package/src/core/model-alias.ts +77 -77
- package/src/core/model-registry.ts +544 -544
- package/src/core/mutex.ts +119 -119
- package/src/core/paths.ts +199 -199
- package/src/core/prompt-limits.ts +214 -214
- package/src/core/reasoning-effort.ts +102 -102
- package/src/core/stream-registry.ts +96 -96
- package/src/core/waf-isolation.ts +117 -117
- package/src/core/watchdog.ts +195 -195
- package/src/delete-chats.ts +23 -23
- package/src/index.ts +65 -64
- package/src/login.ts +147 -147
- package/src/reset-cooldowns.ts +11 -11
- package/src/routes/anthropic/index.ts +355 -355
- package/src/routes/anthropic/translate.ts +522 -522
- package/src/routes/anthropic/types.ts +154 -154
- package/src/routes/anthropic/validation.ts +144 -144
- package/src/routes/chat/account.ts +1817 -1817
- package/src/routes/chat/context.ts +241 -241
- package/src/routes/chat/errors.ts +85 -85
- package/src/routes/chat/helpers.ts +268 -268
- package/src/routes/chat/index.ts +618 -618
- package/src/routes/chat/media.ts +285 -285
- package/src/routes/chat/retry-policy.ts +754 -754
- package/src/routes/chat/stop.ts +98 -98
- package/src/routes/chat/streaming.ts +2710 -2710
- package/src/routes/chat/validation.ts +526 -526
- package/src/routes/chat.ts +2 -2
- package/src/routes/completions.ts +290 -290
- package/src/routes/images.ts +139 -139
- package/src/routes/responses/adapter.ts +503 -503
- package/src/routes/responses/index.ts +405 -405
- package/src/routes/responses/state.ts +230 -230
- package/src/routes/responses/streaming.ts +528 -528
- package/src/routes/responses/types.ts +285 -285
- package/src/routes/responses/validation.ts +202 -202
- package/src/routes/upload.ts +731 -731
- package/src/routes/videos.ts +214 -214
- package/src/services/auth-playwright.ts +173 -173
- package/src/services/captcha-coordinator.ts +161 -161
- package/src/services/captcha-solver.ts +553 -553
- package/src/services/chat-cleanup.ts +80 -80
- package/src/services/context-meter.ts +317 -317
- package/src/services/fingerprint.ts +242 -242
- package/src/services/human-behavior.ts +173 -173
- package/src/services/media-generation.ts +1748 -1748
- package/src/services/playwright.ts +2878 -2800
- package/src/services/qwen-chat-pool.ts +345 -345
- package/src/services/qwen-errors.ts +133 -133
- package/src/services/qwen-headers.ts +79 -79
- package/src/services/qwen-thread-state.ts +393 -393
- package/src/services/qwen-url.ts +19 -19
- package/src/services/qwen.ts +3126 -3126
- package/src/services/session-keeper.ts +88 -88
- package/src/services/token-estimation-metrics.ts +118 -118
- package/src/sync/claude-code.ts +75 -75
- package/src/sync/codex.ts +123 -123
- package/src/sync/index.ts +362 -362
- package/src/sync/omp.ts +105 -105
- package/src/sync/opencode.ts +214 -214
- package/src/sync/types.ts +53 -53
- package/src/sync/utils.ts +27 -27
- package/src/sync-clients.ts +189 -189
- package/src/tools/instructions.ts +137 -137
- package/src/tools/manifest.ts +81 -81
- package/src/tools/parser.ts +2989 -2989
- package/src/tools/toolcall-tags.ts +142 -142
- package/src/tui/app.ts +259 -264
- package/src/tui/index.ts +61 -61
- package/src/tui/markdown.ts +258 -258
- package/src/tui/proxy-client.ts +331 -326
- package/src/tui/screen.ts +294 -278
- package/src/tui/server-manager.ts +270 -270
- package/src/tui/theme.ts +432 -432
- package/src/tui/types.ts +33 -33
- package/src/tui/views/accounts-view.ts +656 -656
- package/src/tui/views/chat-view.ts +1018 -823
- package/src/tui/views/logs-view.ts +479 -413
- package/src/tui/views/status-view.ts +204 -204
- package/src/tui/views/storage-view.ts +304 -291
- package/src/tui/views/sync-view.ts +409 -409
- package/src/types/ali-oss.d.ts +32 -32
- package/src/update-cli.ts +121 -0
- package/src/utils/context-truncation.ts +84 -84
- package/src/utils/json.ts +380 -380
- package/src/utils/session-id.ts +37 -37
- package/src/utils/tool-call-guard.ts +84 -84
- 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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
await params.
|
|
379
|
-
await
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
config.playwright.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
params.
|
|
474
|
-
params.
|
|
475
|
-
params.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
watchdog
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
stopSessionKeeper();
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
await
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
closeDatabase();
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
let
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
config.playwright.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
watchdog
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
// is
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|${
|
|
834
|
-
|${center("
|
|
835
|
-
|${
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|${
|
|
839
|
-
|${row("
|
|
840
|
-
|${row("
|
|
841
|
-
|${row("
|
|
842
|
-
|${row("
|
|
843
|
-
|${
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
|
|
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 };
|