qwenproxy-cli 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clean-cache.js +205 -22
- package/dist/delete-chats.js +199 -36
- package/dist/index.js +492 -57
- package/dist/login.js +95 -24
- package/dist/reset-cooldowns.js +205 -22
- package/package.json +3 -3
- package/src/api/server.ts +1 -0
- package/src/core/daily-usage.ts +276 -0
- package/src/core/database.ts +27 -1
- package/src/core/version.ts +1 -1
- package/src/routes/chat/account.ts +43 -10
- package/src/routes/chat/index.ts +1 -1
- package/src/routes/chat/streaming.ts +31 -3
- package/src/services/captcha-coordinator.ts +12 -7
- package/src/services/playwright.ts +76 -24
- package/src/services/qwen.ts +130 -16
- 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
|
}
|
package/src/core/version.ts
CHANGED
|
@@ -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([
|
|
@@ -1487,19 +1508,24 @@ async function tryCreateStreamWithRetry(
|
|
|
1487
1508
|
};
|
|
1488
1509
|
}
|
|
1489
1510
|
|
|
1490
|
-
|
|
1511
|
+
const isSessionExpired =
|
|
1491
1512
|
err instanceof QwenSessionExpiredError ||
|
|
1492
|
-
err
|
|
1493
|
-
|
|
1513
|
+
err?.name === "QwenSessionExpiredError" ||
|
|
1514
|
+
(err instanceof Error &&
|
|
1515
|
+
(err.message.toLowerCase().includes("token has expired") ||
|
|
1516
|
+
err.message.toLowerCase().includes("session expired") ||
|
|
1517
|
+
err.message.toLowerCase().includes("401 não autorizado") ||
|
|
1518
|
+
err.message.toLowerCase().includes("unauthorized")));
|
|
1519
|
+
|
|
1520
|
+
if (isSessionExpired) {
|
|
1494
1521
|
console.warn(
|
|
1495
|
-
`🔄 [Chat] Session expired for ${currentAccountEmail} (${currentAccountId}). Attempting re-login...`,
|
|
1522
|
+
`🔄 [Chat] Session expired for ${currentAccountEmail} (${currentAccountId}). Attempting silent token refresh/re-login...`,
|
|
1496
1523
|
);
|
|
1497
1524
|
const reLoginOk = await attemptRelogin(
|
|
1498
1525
|
currentAccountId,
|
|
1499
1526
|
currentAccountEmail,
|
|
1500
1527
|
);
|
|
1501
1528
|
if (reLoginOk) continue;
|
|
1502
|
-
return { success: false, error: err };
|
|
1503
1529
|
}
|
|
1504
1530
|
|
|
1505
1531
|
|
|
@@ -1735,6 +1761,13 @@ async function tryCreateStreamWithRetry(
|
|
|
1735
1761
|
currentAccountId = nextAccount.id;
|
|
1736
1762
|
currentAccountEmail = maskEmail(nextAccount.email);
|
|
1737
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
|
+
}
|
|
1738
1771
|
|
|
1739
1772
|
// Account switch always rebuilds a fresh upstream chat with full history.
|
|
1740
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,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
setToolCapNotice,
|
|
19
19
|
} from "../../services/qwen.ts";
|
|
20
20
|
import { acquireUpstreamStream } from "./account.ts";
|
|
21
|
+
import { loadAccounts } from "../../core/accounts.ts";
|
|
21
22
|
import { markAccountRateLimited, computeQuotaCooldownMs } from "../../core/account-manager.ts";
|
|
22
23
|
import {
|
|
23
24
|
clearTemporaryBusy,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
logTokenEstimationSample,
|
|
61
62
|
type TokenEstimationContext,
|
|
62
63
|
} from "../../services/token-estimation-metrics.ts";
|
|
64
|
+
import { recordTurnUsage } from "../../core/daily-usage.ts";
|
|
63
65
|
import {
|
|
64
66
|
enrichUsageWithContextMeter,
|
|
65
67
|
getContextMeterHeaders,
|
|
@@ -781,6 +783,10 @@ export async function processNonStreamingResponse(
|
|
|
781
783
|
context: currentTokenEstimationContext,
|
|
782
784
|
});
|
|
783
785
|
|
|
786
|
+
if (activeAccountId && usage) {
|
|
787
|
+
recordTurnUsage(activeAccountId, usage, body.model);
|
|
788
|
+
}
|
|
789
|
+
|
|
784
790
|
// The response was fully processed: persist the next-turn parent.
|
|
785
791
|
if (pendingParentId) {
|
|
786
792
|
rememberParent(pendingParentId);
|
|
@@ -900,6 +906,8 @@ export async function processStreamingResponse(
|
|
|
900
906
|
let activeReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
901
907
|
let invalidInputSameAccountRetries = 0;
|
|
902
908
|
let chatInProgressSameAccountRetries = 0;
|
|
909
|
+
const totalConfiguredAccounts = loadAccounts().length;
|
|
910
|
+
let quotaRotationsLeft = Math.min(Math.max(1, totalConfiguredAccounts - 1), 6);
|
|
903
911
|
// Set when the terminal [DONE] reached the client. The `| recovered`
|
|
904
912
|
// suffix on Stream done must only appear for attempts that COMPLETED after
|
|
905
913
|
// a mid-stream retry, not for failed attempts that consumed retries.
|
|
@@ -1372,7 +1380,6 @@ export async function processStreamingResponse(
|
|
|
1372
1380
|
!normalizedError ||
|
|
1373
1381
|
(clientDisconnected && !gracePending) ||
|
|
1374
1382
|
c.req.raw.signal.aborted ||
|
|
1375
|
-
retryContext.retriesLeft <= 0 ||
|
|
1376
1383
|
!midStreamRetry ||
|
|
1377
1384
|
emittedModelOutput
|
|
1378
1385
|
) {
|
|
@@ -1384,6 +1391,19 @@ export async function processStreamingResponse(
|
|
|
1384
1391
|
});
|
|
1385
1392
|
if (!policy.retryable) return false;
|
|
1386
1393
|
|
|
1394
|
+
const isRealQuotaError =
|
|
1395
|
+
policy.reason === "quota_or_rate_limit" &&
|
|
1396
|
+
(policy.accountCooldownReason === "RateLimited" ||
|
|
1397
|
+
policy.accountCooldownReason === "QuotaExceeded");
|
|
1398
|
+
|
|
1399
|
+
const hasBudget = isRealQuotaError
|
|
1400
|
+
? quotaRotationsLeft > 0
|
|
1401
|
+
: retryContext.retriesLeft > 0;
|
|
1402
|
+
|
|
1403
|
+
if (!hasBudget) {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1387
1407
|
// Full recovery decision — same rationale as the create-path policy
|
|
1388
1408
|
// log: the failure line shows the error, this line shows WHY the
|
|
1389
1409
|
// chosen action (retry same / switch / new chat / cooldown) was taken.
|
|
@@ -1397,9 +1417,13 @@ export async function processStreamingResponse(
|
|
|
1397
1417
|
invalidateLogicalThreadParent(midStreamRetry.sessionId);
|
|
1398
1418
|
}
|
|
1399
1419
|
|
|
1400
|
-
|
|
1420
|
+
if (isRealQuotaError) {
|
|
1421
|
+
quotaRotationsLeft--;
|
|
1422
|
+
} else {
|
|
1423
|
+
retryContext.retriesLeft--;
|
|
1424
|
+
}
|
|
1401
1425
|
console.warn(
|
|
1402
|
-
`🔄 [Chat] Stream recovery | account=${currentAccountId} | reason=${policy.reason} | error=${normalizedError.message.substring(0, 150)} | retries_left=${retryContext.retriesLeft}`,
|
|
1426
|
+
`🔄 [Chat] Stream recovery | account=${currentAccountId} | reason=${policy.reason} | error=${normalizedError.message.substring(0, 150)} | retries_left=${isRealQuotaError ? quotaRotationsLeft : retryContext.retriesLeft}`,
|
|
1403
1427
|
);
|
|
1404
1428
|
|
|
1405
1429
|
const retryInvalidInputOnSameAccount =
|
|
@@ -2566,6 +2590,10 @@ export async function processStreamingResponse(
|
|
|
2566
2590
|
mode: "stream",
|
|
2567
2591
|
context: currentTokenEstimationContext,
|
|
2568
2592
|
});
|
|
2593
|
+
|
|
2594
|
+
if (activeAccountId && usage) {
|
|
2595
|
+
recordTurnUsage(activeAccountId, usage, body.model);
|
|
2596
|
+
}
|
|
2569
2597
|
} else {
|
|
2570
2598
|
if (isToolcallDebugEnabled()) {
|
|
2571
2599
|
logger.debug(
|
|
@@ -21,6 +21,11 @@ const CHALLENGE_PATH_MARKER = "_____tmd_____";
|
|
|
21
21
|
*/
|
|
22
22
|
const FAILED_RECOVERY_BACKOFF_MS = 30_000;
|
|
23
23
|
const lastFailedRecoveryAt = new Map<string, number>();
|
|
24
|
+
const activeCaptchaRecoveries = new Set<string>();
|
|
25
|
+
|
|
26
|
+
export function isCaptchaRecoveryActive(accountId: string): boolean {
|
|
27
|
+
return activeCaptchaRecoveries.has(accountId);
|
|
28
|
+
}
|
|
24
29
|
|
|
25
30
|
async function gotoBestEffort(page: Page, url: string): Promise<void> {
|
|
26
31
|
// A WAF-blocked navigation can time out while still having rendered the
|
|
@@ -105,13 +110,6 @@ export async function recoverBaxiaCaptcha(
|
|
|
105
110
|
|
|
106
111
|
metrics.increment("captcha.challenges.detected", 1, { solver });
|
|
107
112
|
|
|
108
|
-
const challengeUrl = options.challengeBody
|
|
109
|
-
? extractBaxiaChallengeUrl(options.challengeBody, config.qwen.baseUrl)
|
|
110
|
-
: null;
|
|
111
|
-
|
|
112
|
-
// The slider itself waits up to 5s for each attempt. Keep the page
|
|
113
|
-
// operation alive for the full bounded solver budget so a slow challenge
|
|
114
|
-
// cannot be mistaken for a stuck browser. Do not clamp to timeouts.page.
|
|
115
113
|
// Two navigations (open the challenge, return to the chat page) are part of
|
|
116
114
|
// the recovery, so their budget belongs in the same total.
|
|
117
115
|
const solverOperationTimeoutMs = Math.max(
|
|
@@ -122,6 +120,11 @@ export async function recoverBaxiaCaptcha(
|
|
|
122
120
|
2 * CHALLENGE_NAVIGATION_TIMEOUT_MS,
|
|
123
121
|
);
|
|
124
122
|
|
|
123
|
+
const challengeUrl = options.challengeBody
|
|
124
|
+
? extractBaxiaChallengeUrl(options.challengeBody, config.qwen.baseUrl)
|
|
125
|
+
: null;
|
|
126
|
+
|
|
127
|
+
activeCaptchaRecoveries.add(accountId);
|
|
125
128
|
try {
|
|
126
129
|
// recoverOnTimeout: false ensures that if the captcha solve times out,
|
|
127
130
|
// withAccountPage does NOT destructively kill the browser context, which
|
|
@@ -160,5 +163,7 @@ export async function recoverBaxiaCaptcha(
|
|
|
160
163
|
detail,
|
|
161
164
|
});
|
|
162
165
|
return false;
|
|
166
|
+
} finally {
|
|
167
|
+
activeCaptchaRecoveries.delete(accountId);
|
|
163
168
|
}
|
|
164
169
|
}
|