qwenproxy-cli 1.5.3 → 1.6.1
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/dist/clean-cache.js +252 -26
- package/dist/delete-chats.js +206 -37
- package/dist/index.js +548 -74
- package/dist/login.js +144 -28
- package/dist/reset-cooldowns.js +252 -26
- package/dist/sync-clients.js +2 -2
- package/package.json +3 -3
- package/src/api/server.ts +1 -0
- package/src/clean-cache.ts +3 -0
- package/src/core/config.ts +2 -2
- package/src/core/daily-usage.ts +276 -0
- package/src/core/database.ts +27 -1
- package/src/core/process-guard.ts +77 -0
- package/src/core/version.ts +1 -1
- package/src/delete-chats.ts +3 -0
- package/src/index.ts +3 -29
- package/src/login.ts +2 -0
- package/src/routes/chat/account.ts +33 -5
- package/src/routes/chat/index.ts +1 -1
- package/src/routes/chat/retry-policy.ts +30 -0
- package/src/routes/chat/streaming.ts +72 -4
- package/src/services/captcha-coordinator.ts +12 -7
- package/src/services/chat-cleanup.ts +5 -0
- package/src/services/playwright.ts +82 -26
- package/src/services/qwen.ts +65 -9
- package/src/tui/proxy-client.ts +11 -1
- package/src/tui/types.ts +11 -0
- package/src/tui/views/accounts-view.ts +19 -2
- package/src/tui/views/status-view.ts +37 -4
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QwenProxy - Real-time Daily Token & Quota Metrics Engine
|
|
3
|
+
*
|
|
4
|
+
* Tracks empirical per-account and pool-wide daily usage (prompt & completion tokens, turn counts)
|
|
5
|
+
* keyed strictly by UTC date so it resets automatically every day at 00:00 UTC (21:00 BRT).
|
|
6
|
+
*
|
|
7
|
+
* INVARIANT: This is PURELY an observability / monitoring metric for the user.
|
|
8
|
+
* It NEVER throttles, blocks, or rejects requests locally.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getDatabase } from "./database.ts";
|
|
12
|
+
import { loadAccounts } from "./accounts.ts";
|
|
13
|
+
import { mapClientModelToQwen } from "./model-alias.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calibrated daily output-tokens baseline per account.
|
|
17
|
+
* Standard baseline is based on the primary coding model (Plus family): 500,000 output tokens.
|
|
18
|
+
*/
|
|
19
|
+
export const STANDARD_DAILY_TOKEN_BASELINE = 500_000;
|
|
20
|
+
export const DAILY_ACCOUNT_TOKEN_BASELINE = STANDARD_DAILY_TOKEN_BASELINE; // backward-compat
|
|
21
|
+
|
|
22
|
+
export const MODEL_DAILY_OUTPUT_BASELINES: Record<string, number> = {
|
|
23
|
+
max: 280_000,
|
|
24
|
+
plus: 500_000,
|
|
25
|
+
omni: 620_000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the normalization weight for a model relative to the standard 500k Plus baseline.
|
|
30
|
+
* - Max models (280k): 500/280 = ~1.7857x (consumes quota faster)
|
|
31
|
+
* - Plus models (500k): 1.0x (standard baseline)
|
|
32
|
+
* - Omni/Flash models (620k): 500/620 = ~0.8065x (lasts longer)
|
|
33
|
+
*/
|
|
34
|
+
export function getModelQuotaWeight(model?: string): number {
|
|
35
|
+
if (!model) return 1.0;
|
|
36
|
+
const resolved = mapClientModelToQwen(model).toLowerCase();
|
|
37
|
+
if (resolved.includes("max")) {
|
|
38
|
+
return STANDARD_DAILY_TOKEN_BASELINE / MODEL_DAILY_OUTPUT_BASELINES.max;
|
|
39
|
+
}
|
|
40
|
+
if (resolved.includes("omni") || resolved.includes("flash")) {
|
|
41
|
+
return STANDARD_DAILY_TOKEN_BASELINE / MODEL_DAILY_OUTPUT_BASELINES.omni;
|
|
42
|
+
}
|
|
43
|
+
return 1.0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AccountDailyUsage {
|
|
47
|
+
accountId: string;
|
|
48
|
+
dateUtc: string;
|
|
49
|
+
promptTokens: number;
|
|
50
|
+
completionTokens: number;
|
|
51
|
+
weightedTokens: number;
|
|
52
|
+
totalTokens: number;
|
|
53
|
+
turnsCount: number;
|
|
54
|
+
usagePercent: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PoolDailyUsageSummary {
|
|
58
|
+
dateUtc: string;
|
|
59
|
+
totalPromptTokens: number;
|
|
60
|
+
totalCompletionTokens: number;
|
|
61
|
+
totalWeightedTokens: number;
|
|
62
|
+
totalTokens: number;
|
|
63
|
+
totalTurns: number;
|
|
64
|
+
poolCapacityTokens: number;
|
|
65
|
+
poolUsagePercent: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Formats a timestamp into YYYY-MM-DD in UTC timezone. */
|
|
69
|
+
export function getUtcDateString(timestampMs: number = Date.now()): string {
|
|
70
|
+
return new Date(timestampMs).toISOString().slice(0, 10);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Records tokens and turn count for a given account on the current UTC date.
|
|
75
|
+
* Automatically upserts into SQLite `account_daily_usage`.
|
|
76
|
+
*/
|
|
77
|
+
export function recordTurnUsage(
|
|
78
|
+
accountId: string,
|
|
79
|
+
usage: {
|
|
80
|
+
prompt_tokens?: number;
|
|
81
|
+
completion_tokens?: number;
|
|
82
|
+
total_tokens?: number;
|
|
83
|
+
},
|
|
84
|
+
modelOrDate?: string,
|
|
85
|
+
maybeDateUtc?: string,
|
|
86
|
+
): void {
|
|
87
|
+
if (!accountId || accountId === "global") return;
|
|
88
|
+
|
|
89
|
+
let model: string | undefined;
|
|
90
|
+
let dateUtc: string;
|
|
91
|
+
|
|
92
|
+
if (modelOrDate && /^\d{4}-\d{2}-\d{2}$/.test(modelOrDate)) {
|
|
93
|
+
dateUtc = modelOrDate;
|
|
94
|
+
model = undefined;
|
|
95
|
+
} else {
|
|
96
|
+
model = modelOrDate;
|
|
97
|
+
dateUtc = maybeDateUtc || getUtcDateString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const prompt = Math.max(0, Number(usage.prompt_tokens) || 0);
|
|
101
|
+
const completion = Math.max(0, Number(usage.completion_tokens) || 0);
|
|
102
|
+
const total = Math.max(
|
|
103
|
+
prompt + completion,
|
|
104
|
+
Number(usage.total_tokens) || prompt + completion,
|
|
105
|
+
);
|
|
106
|
+
const weight = getModelQuotaWeight(model);
|
|
107
|
+
const weighted = completion * weight;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const db = getDatabase();
|
|
111
|
+
db.prepare(
|
|
112
|
+
`
|
|
113
|
+
INSERT INTO account_daily_usage (
|
|
114
|
+
account_id, date_utc, prompt_tokens, completion_tokens, weighted_tokens, total_tokens, turns_count, updated_at
|
|
115
|
+
) VALUES (
|
|
116
|
+
?, ?, ?, ?, ?, ?, 1, datetime('now')
|
|
117
|
+
)
|
|
118
|
+
ON CONFLICT(account_id, date_utc) DO UPDATE SET
|
|
119
|
+
prompt_tokens = prompt_tokens + excluded.prompt_tokens,
|
|
120
|
+
completion_tokens = completion_tokens + excluded.completion_tokens,
|
|
121
|
+
weighted_tokens = weighted_tokens + excluded.weighted_tokens,
|
|
122
|
+
total_tokens = total_tokens + excluded.total_tokens,
|
|
123
|
+
turns_count = turns_count + 1,
|
|
124
|
+
updated_at = datetime('now')
|
|
125
|
+
`,
|
|
126
|
+
).run(accountId, dateUtc, prompt, completion, weighted, total);
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
// Non-fatal telemetry: never fail a user completion if DB recording fails
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Returns today's usage statistics for a specific account.
|
|
134
|
+
*/
|
|
135
|
+
export function getAccountDailyUsage(
|
|
136
|
+
accountId: string,
|
|
137
|
+
dateUtc: string = getUtcDateString(),
|
|
138
|
+
): AccountDailyUsage {
|
|
139
|
+
try {
|
|
140
|
+
const db = getDatabase();
|
|
141
|
+
const row = db
|
|
142
|
+
.prepare(
|
|
143
|
+
`SELECT prompt_tokens, completion_tokens, weighted_tokens, total_tokens, turns_count
|
|
144
|
+
FROM account_daily_usage
|
|
145
|
+
WHERE account_id = ? AND date_utc = ?`,
|
|
146
|
+
)
|
|
147
|
+
.get(accountId, dateUtc) as any;
|
|
148
|
+
|
|
149
|
+
const promptTokens = Number(row?.prompt_tokens) || 0;
|
|
150
|
+
const completionTokens = Number(row?.completion_tokens) || 0;
|
|
151
|
+
const weightedTokens = Number(row?.weighted_tokens) || completionTokens;
|
|
152
|
+
const totalTokens = Number(row?.total_tokens) || 0;
|
|
153
|
+
const turnsCount = Number(row?.turns_count) || 0;
|
|
154
|
+
|
|
155
|
+
const usagePercent = Math.min(
|
|
156
|
+
100,
|
|
157
|
+
Math.round((weightedTokens / STANDARD_DAILY_TOKEN_BASELINE) * 100),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
accountId,
|
|
162
|
+
dateUtc,
|
|
163
|
+
promptTokens,
|
|
164
|
+
completionTokens,
|
|
165
|
+
weightedTokens,
|
|
166
|
+
totalTokens,
|
|
167
|
+
turnsCount,
|
|
168
|
+
usagePercent,
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
return {
|
|
172
|
+
accountId,
|
|
173
|
+
dateUtc,
|
|
174
|
+
promptTokens: 0,
|
|
175
|
+
completionTokens: 0,
|
|
176
|
+
weightedTokens: 0,
|
|
177
|
+
totalTokens: 0,
|
|
178
|
+
turnsCount: 0,
|
|
179
|
+
usagePercent: 0,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Returns daily usage mapped by account_id for the given UTC date.
|
|
186
|
+
*/
|
|
187
|
+
export function getAllAccountsDailyUsage(
|
|
188
|
+
dateUtc: string = getUtcDateString(),
|
|
189
|
+
): Map<string, AccountDailyUsage> {
|
|
190
|
+
const result = new Map<string, AccountDailyUsage>();
|
|
191
|
+
try {
|
|
192
|
+
const db = getDatabase();
|
|
193
|
+
const rows = db
|
|
194
|
+
.prepare(
|
|
195
|
+
`SELECT account_id, prompt_tokens, completion_tokens, weighted_tokens, total_tokens, turns_count
|
|
196
|
+
FROM account_daily_usage
|
|
197
|
+
WHERE date_utc = ?`,
|
|
198
|
+
)
|
|
199
|
+
.all(dateUtc) as any[];
|
|
200
|
+
|
|
201
|
+
for (const row of rows) {
|
|
202
|
+
const promptTokens = Number(row.prompt_tokens) || 0;
|
|
203
|
+
const completionTokens = Number(row.completion_tokens) || 0;
|
|
204
|
+
const weightedTokens = Number(row.weighted_tokens) || completionTokens;
|
|
205
|
+
const totalTokens = Number(row.total_tokens) || 0;
|
|
206
|
+
const turnsCount = Number(row.turns_count) || 0;
|
|
207
|
+
const usagePercent = Math.min(
|
|
208
|
+
100,
|
|
209
|
+
Math.round((weightedTokens / STANDARD_DAILY_TOKEN_BASELINE) * 100),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
result.set(row.account_id, {
|
|
213
|
+
accountId: row.account_id,
|
|
214
|
+
dateUtc,
|
|
215
|
+
promptTokens,
|
|
216
|
+
completionTokens,
|
|
217
|
+
weightedTokens,
|
|
218
|
+
totalTokens,
|
|
219
|
+
turnsCount,
|
|
220
|
+
usagePercent,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Computes the aggregate pool daily usage and percentage.
|
|
229
|
+
*/
|
|
230
|
+
export function getPoolDailyUsageSummary(
|
|
231
|
+
targetAccountIds?: string[],
|
|
232
|
+
dateUtc: string = getUtcDateString(),
|
|
233
|
+
): PoolDailyUsageSummary {
|
|
234
|
+
const accounts =
|
|
235
|
+
targetAccountIds && targetAccountIds.length > 0
|
|
236
|
+
? targetAccountIds
|
|
237
|
+
: loadAccounts().map((a) => a.id);
|
|
238
|
+
|
|
239
|
+
const accountCount = Math.max(1, accounts.length);
|
|
240
|
+
const poolCapacityTokens = accountCount * STANDARD_DAILY_TOKEN_BASELINE;
|
|
241
|
+
|
|
242
|
+
const usageMap = getAllAccountsDailyUsage(dateUtc);
|
|
243
|
+
|
|
244
|
+
let totalPromptTokens = 0;
|
|
245
|
+
let totalCompletionTokens = 0;
|
|
246
|
+
let totalWeightedTokens = 0;
|
|
247
|
+
let totalTokens = 0;
|
|
248
|
+
let totalTurns = 0;
|
|
249
|
+
|
|
250
|
+
for (const id of accounts) {
|
|
251
|
+
const usage = usageMap.get(id);
|
|
252
|
+
if (usage) {
|
|
253
|
+
totalPromptTokens += usage.promptTokens;
|
|
254
|
+
totalCompletionTokens += usage.completionTokens;
|
|
255
|
+
totalWeightedTokens += usage.weightedTokens;
|
|
256
|
+
totalTokens += usage.totalTokens;
|
|
257
|
+
totalTurns += usage.turnsCount;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const poolUsagePercent = Math.min(
|
|
262
|
+
100,
|
|
263
|
+
Math.round((totalWeightedTokens / poolCapacityTokens) * 100),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
dateUtc,
|
|
268
|
+
totalPromptTokens,
|
|
269
|
+
totalCompletionTokens,
|
|
270
|
+
totalWeightedTokens,
|
|
271
|
+
totalTokens,
|
|
272
|
+
totalTurns,
|
|
273
|
+
poolCapacityTokens,
|
|
274
|
+
poolUsagePercent,
|
|
275
|
+
};
|
|
276
|
+
}
|
package/src/core/database.ts
CHANGED
|
@@ -170,8 +170,28 @@ function runMigrations(db: Database.Database): void {
|
|
|
170
170
|
instruction_hash TEXT NOT NULL,
|
|
171
171
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
172
172
|
);
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS account_daily_usage (
|
|
175
|
+
account_id TEXT NOT NULL,
|
|
176
|
+
date_utc TEXT NOT NULL,
|
|
177
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
178
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
|
179
|
+
weighted_tokens REAL NOT NULL DEFAULT 0,
|
|
180
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
181
|
+
turns_count INTEGER NOT NULL DEFAULT 0,
|
|
182
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
183
|
+
PRIMARY KEY (account_id, date_utc)
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON account_daily_usage(date_utc);
|
|
173
187
|
`);
|
|
174
188
|
|
|
189
|
+
try {
|
|
190
|
+
db.exec(`ALTER TABLE account_daily_usage ADD COLUMN weighted_tokens REAL DEFAULT 0;`);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (!isDuplicateColumnError(err)) throw err;
|
|
193
|
+
}
|
|
194
|
+
|
|
175
195
|
// Cooldown persistence columns — wrapped in try-catch because
|
|
176
196
|
// SQLite doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN.
|
|
177
197
|
try {
|
|
@@ -341,7 +361,11 @@ export function saveAuthSession(
|
|
|
341
361
|
) VALUES (
|
|
342
362
|
?, ?, ?, ?, ?, ?,
|
|
343
363
|
?, ?, ?, ?,
|
|
344
|
-
?,
|
|
364
|
+
COALESCE(?, (SELECT user_id FROM qwen_auth_sessions WHERE account_id = ?)),
|
|
365
|
+
?,
|
|
366
|
+
COALESCE(?, (SELECT refresh_token FROM qwen_auth_sessions WHERE account_id = ?)),
|
|
367
|
+
?,
|
|
368
|
+
datetime('now')
|
|
345
369
|
)
|
|
346
370
|
`);
|
|
347
371
|
stmt.run(
|
|
@@ -356,8 +380,10 @@ export function saveAuthSession(
|
|
|
356
380
|
session.secChUaPlatform || null,
|
|
357
381
|
session.version || null,
|
|
358
382
|
session.userId || null,
|
|
383
|
+
accountId,
|
|
359
384
|
session.tokenExpiresAt || null,
|
|
360
385
|
session.refreshToken || null,
|
|
386
|
+
accountId,
|
|
361
387
|
session.capturedAt ?? Date.now(),
|
|
362
388
|
);
|
|
363
389
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process teardown error guard.
|
|
3
|
+
*
|
|
4
|
+
* Catches unhandled rejections and uncaught exceptions that arise from
|
|
5
|
+
* benign browser/CDP connection closures (e.g. ProtocolError: session closed,
|
|
6
|
+
* Network.setCacheDisabled, target crashed during shutdown) and prevents
|
|
7
|
+
* them from terminating the Node.js process.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function isPlaywrightAlreadyClosedMessage(message: string): boolean {
|
|
11
|
+
if (!message) return false;
|
|
12
|
+
return (
|
|
13
|
+
message.includes("Target page, context or browser has been closed") ||
|
|
14
|
+
message.includes("Browser has been closed") ||
|
|
15
|
+
message.includes("Target closed") ||
|
|
16
|
+
message.includes("Target crashed") ||
|
|
17
|
+
message.includes("Page crashed") ||
|
|
18
|
+
message.includes("Assertion error") ||
|
|
19
|
+
message.includes("Cannot find parent object") ||
|
|
20
|
+
message.includes("Connection closed") ||
|
|
21
|
+
message.includes("session closed") ||
|
|
22
|
+
message.includes("Session closed") ||
|
|
23
|
+
message.includes("Network.setCacheDisabled") ||
|
|
24
|
+
message.includes("Protocol error") ||
|
|
25
|
+
message.includes("storageState timed out")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isBenignTeardownError(error: unknown): boolean {
|
|
30
|
+
if (!error) return false;
|
|
31
|
+
const message =
|
|
32
|
+
error instanceof Error
|
|
33
|
+
? error.message
|
|
34
|
+
: typeof error === "object" && "message" in error
|
|
35
|
+
? String((error as any).message)
|
|
36
|
+
: String(error);
|
|
37
|
+
return isPlaywrightAlreadyClosedMessage(message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let guardsInstalled = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Installs process-wide handlers for uncaughtException and unhandledRejection
|
|
44
|
+
* to guard against asynchronous Playwright/CDP driver teardown crashes.
|
|
45
|
+
*/
|
|
46
|
+
export function installProcessTeardownGuards(label = "Process"): void {
|
|
47
|
+
if (guardsInstalled) return;
|
|
48
|
+
guardsInstalled = true;
|
|
49
|
+
|
|
50
|
+
process.on("uncaughtException", (error: unknown) => {
|
|
51
|
+
if (isBenignTeardownError(error)) {
|
|
52
|
+
const msg =
|
|
53
|
+
error instanceof Error
|
|
54
|
+
? error.message
|
|
55
|
+
: typeof error === "object" && error !== null && "message" in error
|
|
56
|
+
? String((error as any).message)
|
|
57
|
+
: String(error);
|
|
58
|
+
console.warn(`⚠️ [${label}] Handled benign driver teardown exception: ${msg}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.error(`❌ [${label}] Uncaught Exception:`, error);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
process.on("unhandledRejection", (reason: unknown) => {
|
|
65
|
+
if (isBenignTeardownError(reason)) {
|
|
66
|
+
const msg =
|
|
67
|
+
reason instanceof Error
|
|
68
|
+
? reason.message
|
|
69
|
+
: typeof reason === "object" && reason !== null && "message" in reason
|
|
70
|
+
? String((reason as any).message)
|
|
71
|
+
: String(reason);
|
|
72
|
+
console.warn(`⚠️ [${label}] Handled benign driver teardown rejection: ${msg}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.error(`❌ [${label}] Unhandled Rejection:`, reason);
|
|
76
|
+
});
|
|
77
|
+
}
|
package/src/core/version.ts
CHANGED
package/src/delete-chats.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
+
import { installProcessTeardownGuards } from "./core/process-guard.ts";
|
|
2
3
|
import { deleteChatsForConfiguredAccounts } from "./services/chat-cleanup.ts";
|
|
3
4
|
|
|
5
|
+
installProcessTeardownGuards("DeleteChats");
|
|
6
|
+
|
|
4
7
|
async function run(): Promise<void> {
|
|
5
8
|
console.log("🗑️ [DeleteChats] Using Playwright sessions");
|
|
6
9
|
|
package/src/index.ts
CHANGED
|
@@ -16,36 +16,10 @@ if (fs.existsSync(envPath)) {
|
|
|
16
16
|
} else {
|
|
17
17
|
dotenv.config({ quiet: true })
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
process.on('uncaughtException', async (error: unknown) => {
|
|
21
|
-
const { isPlaywrightAlreadyClosedError } = await import('./services/playwright.ts');
|
|
22
|
-
if (isPlaywrightAlreadyClosedError(error)) {
|
|
23
|
-
const msg =
|
|
24
|
-
error instanceof Error
|
|
25
|
-
? error.message
|
|
26
|
-
: typeof error === 'object' && error !== null && 'message' in error
|
|
27
|
-
? String((error as any).message)
|
|
28
|
-
: String(error);
|
|
29
|
-
console.warn(`⚠️ [Playwright] Handled benign driver teardown exception: ${msg}`);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
console.error('❌ [Process] Uncaught Exception:', error);
|
|
33
|
-
});
|
|
19
|
+
import { installProcessTeardownGuards } from './core/process-guard.ts'
|
|
34
20
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (isPlaywrightAlreadyClosedError(reason)) {
|
|
38
|
-
const msg =
|
|
39
|
-
reason instanceof Error
|
|
40
|
-
? reason.message
|
|
41
|
-
: typeof reason === 'object' && reason !== null && 'message' in reason
|
|
42
|
-
? String((reason as any).message)
|
|
43
|
-
: String(reason);
|
|
44
|
-
console.warn(`⚠️ [Playwright] Handled benign driver teardown rejection: ${msg}`);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
console.error('❌ [Process] Unhandled Rejection:', reason);
|
|
48
|
-
});
|
|
21
|
+
// Prevent benign asynchronous driver/browser teardown exceptions from crashing the server
|
|
22
|
+
installProcessTeardownGuards("Playwright");
|
|
49
23
|
import { startServer } from './api/server.js'
|
|
50
24
|
const isTui = process.argv.includes('--tui') || process.env.QWEN_TUI === 'true'
|
|
51
25
|
|
package/src/login.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
import { maskEmail } from "./core/logger.ts";
|
|
10
10
|
import * as readline from "readline";
|
|
11
11
|
import * as dotenv from "dotenv";
|
|
12
|
+
import { installProcessTeardownGuards } from "./core/process-guard.ts";
|
|
12
13
|
|
|
13
14
|
dotenv.config();
|
|
15
|
+
installProcessTeardownGuards("Login");
|
|
14
16
|
|
|
15
17
|
const rl = readline.createInterface({
|
|
16
18
|
input: process.stdin,
|
|
@@ -271,8 +271,13 @@ export function resolveInitialAccount(
|
|
|
271
271
|
|
|
272
272
|
// Explicit preferred account (sticky / same-account retry)
|
|
273
273
|
if (typeof preferredAccountId === "string" && preferredAccountId) {
|
|
274
|
+
const needle = preferredAccountId.toLowerCase();
|
|
274
275
|
const preferred = configuredAccounts.find(
|
|
275
|
-
(candidate) =>
|
|
276
|
+
(candidate) =>
|
|
277
|
+
candidate.id === preferredAccountId ||
|
|
278
|
+
candidate.id.toLowerCase().startsWith(needle) ||
|
|
279
|
+
candidate.email.toLowerCase() === needle ||
|
|
280
|
+
candidate.email.toLowerCase().includes(needle),
|
|
276
281
|
);
|
|
277
282
|
if (preferred && !getAccountCooldownInfo(preferred.id)) {
|
|
278
283
|
return { account: preferred, configuredAccounts };
|
|
@@ -939,7 +944,12 @@ async function tryCreateStreamWithRetry(
|
|
|
939
944
|
accountEmail: string,
|
|
940
945
|
): Promise<CreateStreamSuccess | CreateStreamFailure> {
|
|
941
946
|
const maxAttempts = Math.max(1, config.retry.maxAttempts);
|
|
942
|
-
const
|
|
947
|
+
const accounts = loadAccounts();
|
|
948
|
+
const configuredAccountCount = accounts.length;
|
|
949
|
+
const maxAccountSwitches = Math.max(
|
|
950
|
+
config.retry.maxAccountSwitches,
|
|
951
|
+
Math.min(configuredAccountCount - 1, 6),
|
|
952
|
+
);
|
|
943
953
|
let attemptsLeft = maxAttempts;
|
|
944
954
|
let retryDelay = config.retry.baseDelayMs;
|
|
945
955
|
let attempt = 0;
|
|
@@ -956,7 +966,6 @@ async function tryCreateStreamWithRetry(
|
|
|
956
966
|
let chatInProgressOriginAccountEmail: string | null = null;
|
|
957
967
|
let lastAttemptError: any = null;
|
|
958
968
|
let invalidInputSameAccountRetried = false;
|
|
959
|
-
const accounts = loadAccounts();
|
|
960
969
|
const isSingleAccount = accounts.length <= 1;
|
|
961
970
|
let currentAccountId = accountId;
|
|
962
971
|
let currentAccountEmail = accountEmail;
|
|
@@ -1268,7 +1277,18 @@ async function tryCreateStreamWithRetry(
|
|
|
1268
1277
|
const acquireDeadlineMs = config.concurrency.acquireDeadlineMs;
|
|
1269
1278
|
let acquireDeadlineTimer: NodeJS.Timeout | undefined;
|
|
1270
1279
|
const acquireDeadline = new Promise<never>((_, reject) => {
|
|
1271
|
-
|
|
1280
|
+
const checkDeadline = async () => {
|
|
1281
|
+
try {
|
|
1282
|
+
const { isCaptchaRecoveryActive } = await import(
|
|
1283
|
+
"../../services/captcha-coordinator.ts"
|
|
1284
|
+
);
|
|
1285
|
+
if (currentAccountId && isCaptchaRecoveryActive(currentAccountId)) {
|
|
1286
|
+
// Captcha solver is actively solving puzzle in background; extend deadline by 30s
|
|
1287
|
+
acquireDeadlineTimer = setTimeout(checkDeadline, 30_000);
|
|
1288
|
+
acquireDeadlineTimer.unref?.();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
} catch {}
|
|
1272
1292
|
// Abort the losing createQwenStream (it is still queued on the
|
|
1273
1293
|
// stream lock or mid-create); the post-lock signal re-check in
|
|
1274
1294
|
// createQwenStream then throws instead of letting the orphan
|
|
@@ -1279,7 +1299,8 @@ async function tryCreateStreamWithRetry(
|
|
|
1279
1299
|
) as Error & { code?: string };
|
|
1280
1300
|
err.code = "acquire_deadline";
|
|
1281
1301
|
reject(err);
|
|
1282
|
-
}
|
|
1302
|
+
};
|
|
1303
|
+
acquireDeadlineTimer = setTimeout(checkDeadline, acquireDeadlineMs);
|
|
1283
1304
|
acquireDeadlineTimer.unref?.();
|
|
1284
1305
|
});
|
|
1285
1306
|
result = await Promise.race([
|
|
@@ -1740,6 +1761,13 @@ async function tryCreateStreamWithRetry(
|
|
|
1740
1761
|
currentAccountId = nextAccount.id;
|
|
1741
1762
|
currentAccountEmail = maskEmail(nextAccount.email);
|
|
1742
1763
|
accountSwitches++;
|
|
1764
|
+
const isRealQuotaError =
|
|
1765
|
+
policy.reason === "quota_or_rate_limit" &&
|
|
1766
|
+
(policy.accountCooldownReason === "RateLimited" ||
|
|
1767
|
+
policy.accountCooldownReason === "QuotaExceeded");
|
|
1768
|
+
if (isRealQuotaError) {
|
|
1769
|
+
attemptsLeft = Math.max(attemptsLeft, 1);
|
|
1770
|
+
}
|
|
1743
1771
|
|
|
1744
1772
|
// Account switch always rebuilds a fresh upstream chat with full history.
|
|
1745
1773
|
// Do NOT persist sticky binding until create succeeds — premature empty
|
package/src/routes/chat/index.ts
CHANGED
|
@@ -242,7 +242,7 @@ export async function chatCompletions(c: Context) {
|
|
|
242
242
|
updateLogicalThread: parallelEscape ? false : ctx.updateLogicalThread,
|
|
243
243
|
allowThreadReuse: ctx.allowThreadReuse,
|
|
244
244
|
forceNewChat: parallelEscape,
|
|
245
|
-
preferredAccountId: undefined,
|
|
245
|
+
preferredAccountId: c.req.header("x-qwenproxy-account") || undefined,
|
|
246
246
|
messageCount: msgCount,
|
|
247
247
|
fullMessageCount: parsed.messageCount,
|
|
248
248
|
toolsCount: declaredTools.length || undefined,
|
|
@@ -404,6 +404,26 @@ export function isCorruptedChatHistoryError(err: unknown): boolean {
|
|
|
404
404
|
);
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Qwen upstream internal or server-busy errors.
|
|
409
|
+
* Transient server-side failures requiring a fresh chat and account switch.
|
|
410
|
+
*/
|
|
411
|
+
export function isInternalServerError(err: unknown): boolean {
|
|
412
|
+
const code = errCode(err).toLowerCase();
|
|
413
|
+
const message = errMessage(err).toLowerCase();
|
|
414
|
+
return (
|
|
415
|
+
code === "internal_error" ||
|
|
416
|
+
code === "internal_server_error" ||
|
|
417
|
+
code === "server_error" ||
|
|
418
|
+
code === "server_busy" ||
|
|
419
|
+
code === "busy" ||
|
|
420
|
+
message.includes("internal_error") ||
|
|
421
|
+
message.includes("ocorreu um erro inesperado") ||
|
|
422
|
+
message.includes("unexpected error") ||
|
|
423
|
+
message.includes("internal server error")
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
407
427
|
/**
|
|
408
428
|
* Build a RetryAction with sane defaults so each classification branch only
|
|
409
429
|
* spells out the fields it actually changes. Defaults: retryable, no account
|
|
@@ -590,6 +610,16 @@ export function classifyRetryAction(
|
|
|
590
610
|
});
|
|
591
611
|
}
|
|
592
612
|
|
|
613
|
+
if (isInternalServerError(err) && !(err instanceof QwenUpstreamError)) {
|
|
614
|
+
const typed = err as RetryableStreamError;
|
|
615
|
+
return makeRetryAction("upstream_internal_error", {
|
|
616
|
+
switchAccount: typed.switchAccount !== false,
|
|
617
|
+
forceNewChat: true,
|
|
618
|
+
retryWithFullPrompt: true,
|
|
619
|
+
retryAfterMs: typed.retryAfterMs ?? Math.min(baseDelayMs * 2, 3000),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
593
623
|
if (
|
|
594
624
|
isNetworkLikeError(err) ||
|
|
595
625
|
err instanceof QwenUpstreamUnavailableError ||
|