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.
@@ -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
+ }
@@ -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
- ?, ?, ?, ?, datetime('now')
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
+ }
@@ -17,6 +17,6 @@ export function getAppVersion(): string {
17
17
  }
18
18
  }
19
19
  } catch {}
20
- cachedVersion = "v1.5.3";
20
+ cachedVersion = "v1.6.1";
21
21
  return cachedVersion;
22
22
  }
@@ -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
- // Prevent benign asynchronous driver/browser teardown exceptions from crashing the server
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
- process.on('unhandledRejection', async (reason: unknown) => {
36
- const { isPlaywrightAlreadyClosedError } = await import('./services/playwright.ts');
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) => candidate.id === preferredAccountId,
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 maxAccountSwitches = Math.max(0, config.retry.maxAccountSwitches);
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
- acquireDeadlineTimer = setTimeout(() => {
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
- }, acquireDeadlineMs);
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
@@ -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 ||