qwenproxy-cli 1.2.5 → 1.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -174,7 +174,7 @@ const envSchema = z
174
174
  DELETE_ALL_CHATS_ON_SHUTDOWN: z.string().default("false"),
175
175
  AUTO_CLEAN_CHATS_ON_STARTUP: z.string().default("true"),
176
176
  AUTO_CLEAN_ORPHAN_CHATS: z.string().default("true"),
177
- AUTO_CLEAN_CHAT_MAX_AGE_HOURS: z.string().default("24"),
177
+ AUTO_CLEAN_CHAT_MAX_AGE_HOURS: z.string().default("168"),
178
178
  // The Baxia WAF scores live page behavior (pointer/scroll events, open
179
179
  // session) — an account whose page sits frozen for minutes returns a low
180
180
  // trust score and gets TMD-challenged on the next request. On by default;
@@ -384,7 +384,7 @@ export const config = {
384
384
  autoCleanOrphanChats: env.AUTO_CLEAN_ORPHAN_CHATS !== "false",
385
385
  autoCleanChatMaxAgeHours: Math.max(
386
386
  1,
387
- parseInt(env.AUTO_CLEAN_CHAT_MAX_AGE_HOURS) || 24,
387
+ parseInt(env.AUTO_CLEAN_CHAT_MAX_AGE_HOURS) || 168,
388
388
  ),
389
389
  sendBxUa: env.QWEN_SEND_BX_UA === "true",
390
390
  /** Deployed web bundle version sent as the `version` API header. */
@@ -79,6 +79,13 @@ export function recordServerLog(level: "INFO" | "WARN" | "ERROR", text: string):
79
79
  .replace(/^(?:\[?(?:INFO|WARN|WARNING|ERROR|ERR|DEBUG)\]?\s+)+/i, "")
80
80
  .replace(/([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}]\uFE0F?)\s{2,}/gu, "$1 ");
81
81
  if (!line) continue;
82
+
83
+ // Prevent identical consecutive duplicate logs in the same second
84
+ const last = logHistory[logHistory.length - 1];
85
+ if (last && last.time === time && last.level === level && last.message === line) {
86
+ continue;
87
+ }
88
+
82
89
  const entry: ServerLogMessage = { time, level, message: line };
83
90
  logHistory.push(entry);
84
91
  if (logHistory.length > MAX_LOG_HISTORY) {
@@ -1150,27 +1150,19 @@ async function tryCreateStreamWithRetry(
1150
1150
  const hasRequestPersonalization =
1151
1151
  params.requestPersonalizationInstruction !== null &&
1152
1152
  params.requestPersonalizationInstruction !== undefined;
1153
- const releasePersonalization = hasRequestPersonalization
1154
- ? await acquirePersonalizationLock(currentAccountId)
1155
- : null;
1156
- // A same-session retry (or client disconnect) can abort this request
1157
- // while the personalization sync is still stuck on a hung page op
1158
- // (closed Playwright context / WAF). The sync never resolves, so the
1159
- // finally below would not run and the mutex would stay held for
1160
- // minutes, blocking the retry until its 60s acquire timeout fires.
1161
- // Release the lock immediately on abort instead.
1162
- const onPersonalizationAbort = () => releasePersonalization?.();
1163
- if (combinedSignal.aborted) {
1164
- onPersonalizationAbort();
1165
- } else {
1166
- combinedSignal.addEventListener("abort", onPersonalizationAbort, {
1167
- once: true,
1168
- });
1169
- }
1170
1153
  let result: Awaited<ReturnType<typeof createQwenStream>>;
1171
- try {
1172
- let promptForUpstream = effectivePrompt;
1173
- if (hasRequestPersonalization) {
1154
+ let promptForUpstream = effectivePrompt;
1155
+ if (hasRequestPersonalization) {
1156
+ const releasePersonalization = await acquirePersonalizationLock(currentAccountId);
1157
+ const onPersonalizationAbort = () => releasePersonalization();
1158
+ if (combinedSignal.aborted) {
1159
+ onPersonalizationAbort();
1160
+ } else {
1161
+ combinedSignal.addEventListener("abort", onPersonalizationAbort, {
1162
+ once: true,
1163
+ });
1164
+ }
1165
+ try {
1174
1166
  // Let the hash-based cache in syncQwenRequestPersonalization decide
1175
1167
  // whether to actually POST. A new chat does not imply the account's
1176
1168
  // global settings were reset — only session refresh or profile reset
@@ -1244,12 +1236,25 @@ async function tryCreateStreamWithRetry(
1244
1236
  `personalization sync not confirmed for ${currentAccountEmail}: ${syncFailure ?? "settings response did not confirm the instruction"}`,
1245
1237
  );
1246
1238
  }
1247
- }
1248
- if (logger.isLevelEnabled("info")) {
1249
- console.log(
1250
- `⏱️ [Chat] Acquire: sync | account=${currentAccountEmail} | +${Date.now() - acquireStartedAt}ms`,
1251
- );
1252
- }
1239
+ } finally {
1240
+ combinedSignal.removeEventListener("abort", onPersonalizationAbort);
1241
+ releasePersonalization();
1242
+ }
1243
+
1244
+ if (logger.isLevelEnabled("info")) {
1245
+ console.log(
1246
+ `⏱️ [Chat] Acquire: sync | account=${currentAccountEmail} | +${Date.now() - acquireStartedAt}ms`,
1247
+ );
1248
+ }
1249
+
1250
+ if (combinedSignal.aborted) {
1251
+ accountLease?.release();
1252
+ return {
1253
+ success: false,
1254
+ error: new ClientAbortedError("client aborted during personalization sync"),
1255
+ };
1256
+ }
1257
+ }
1253
1258
 
1254
1259
  assertPromptWithinLimits(
1255
1260
  promptForUpstream,
@@ -1350,13 +1355,6 @@ async function tryCreateStreamWithRetry(
1350
1355
  },
1351
1356
  };
1352
1357
  }
1353
- } finally {
1354
- combinedSignal.removeEventListener(
1355
- "abort",
1356
- onPersonalizationAbort,
1357
- );
1358
- releasePersonalization?.();
1359
- }
1360
1358
 
1361
1359
  // Client cancelled (or a same-session retry superseded us) during the
1362
1360
  // (potentially slow) personalization sync. Bail before createQwenStream
@@ -220,6 +220,7 @@ export function isAccountInitializationError(err: unknown): boolean {
220
220
  return (
221
221
  code === "acquire_deadline" ||
222
222
  message.includes("acquire deadline") ||
223
+ message.includes("header capture timed out") ||
223
224
  message.includes("header capture returned incomplete anti-fraud headers") ||
224
225
  message.includes("required qwen anti-fraud headers are unavailable") ||
225
226
  message.includes("playwright not initialized for account") ||
@@ -479,6 +480,28 @@ export function classifyRetryAction(
479
480
  });
480
481
  }
481
482
 
483
+ // Upstream 401 / Unauthorized on chat creation or API requests:
484
+ // Account session is invalid or expired. Cool down account with AuthInitFailed so the
485
+ // proxy rotates to a valid account instead of looping endlessly on 503s.
486
+ if (
487
+ code === "createchatinvalidresponse" ||
488
+ code === "createchatfailed" ||
489
+ code === "unauthorized" ||
490
+ message.includes("401 não autorizado") ||
491
+ message.includes("não tem permissão para acessar") ||
492
+ message.includes("401 unauthorized") ||
493
+ message.includes('"code":"unauthorized"') ||
494
+ message.includes('"code": "unauthorized"')
495
+ ) {
496
+ return makeRetryAction("account_initialization_failed", {
497
+ switchAccount: true,
498
+ forceNewChat: true,
499
+ retryWithFullPrompt: true,
500
+ retryAfterMs: Math.min(baseDelayMs, 1_000),
501
+ accountCooldownMs: config.concurrency.initFailureCooldownMs,
502
+ accountCooldownReason: "AuthInitFailed",
503
+ });
504
+ }
482
505
  // Specialized recoveries first (even if wrapped as RetryableQwenStreamError)
483
506
  // Corrupted chat history must win over broad "invalid input" matches.
484
507
  // Try a fresh chat on the SAME account first — the corruption is in the
@@ -14,11 +14,11 @@ import {
14
14
  closeAllPlaywright,
15
15
  getActivePlaywrightAccountIds,
16
16
  } from "./playwright.ts";
17
+ import { isChatSessionActive } from "./qwen-thread-state.ts";
18
+ import { hasActiveAccountLease, isAccountBusy } from "../core/account-concurrency.ts";
17
19
  import { isAuthMockEnabled } from "./auth-playwright.ts";
18
20
  import { maskEmail, logger } from "../core/logger.ts";
19
21
  import { config } from "../core/config.ts";
20
- import { isAccountBusy } from "../core/account-concurrency.ts";
21
- import { isChatSessionActive } from "./qwen-thread-state.ts";
22
22
  import { sleep } from "./human-behavior.ts";
23
23
  import { metrics } from "../core/metrics.ts";
24
24
 
@@ -246,11 +246,16 @@ export async function cleanOldChatsForAccount(
246
246
  : new Date(chat.updated_at).getTime();
247
247
 
248
248
  if (now - updatedMs >= maxAgeMs) {
249
+ // Cooperative yield: if account becomes active with a request, stop cleanup immediately
250
+ if (hasActiveAccountLease(accountId) || isAccountBusy(accountId)) {
251
+ break;
252
+ }
253
+
249
254
  const ok = await deleteSingleQwenChat(accountId, chat.id);
250
255
  if (ok) {
251
256
  cleaned++;
252
257
  metrics.increment("chats.cleaned");
253
- await sleep(300);
258
+ await sleep(150);
254
259
  }
255
260
  }
256
261
  }
@@ -281,6 +286,8 @@ export function scheduleStartupChatCleanup(): void {
281
286
  try {
282
287
  const activeIds = getActivePlaywrightAccountIds();
283
288
  for (const accountId of activeIds) {
289
+ // Yield to active requests: skip if account is busy serving a stream or lease
290
+ if (hasActiveAccountLease(accountId) || isAccountBusy(accountId)) continue;
284
291
  await cleanOldChatsForAccount(accountId);
285
292
  await sleep(1_000);
286
293
  }
@@ -2606,7 +2606,9 @@ async function refreshHeadersInternal(
2606
2606
  ),
2607
2607
  });
2608
2608
  const url = page.url();
2609
- if (url.includes("auth") || url.includes("login")) {
2609
+ const isAuthUrl = url.includes("auth") || url.includes("login");
2610
+ const isLoggedIn = isAuthUrl ? false : await isPageLoggedIn(page, 5_000);
2611
+ if (isAuthUrl || !isLoggedIn) {
2610
2612
  console.warn(
2611
2613
  `⚠️ [Playwright] Session expired during refresh for ${accountId}, re-authenticating...`,
2612
2614
  );
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  export function syncClaudeCode(options: SyncOptions): ClientSyncResult {
7
7
  const { filePath, apiKey, baseUrl, model = "qwen3.8-max" } = options;
@@ -27,8 +27,8 @@ export function syncClaudeCode(options: SyncOptions): ClientSyncResult {
27
27
  ANTHROPIC_AUTH_TOKEN: apiKey,
28
28
  ANTHROPIC_MODEL: model,
29
29
  ANTHROPIC_CUSTOM_MODEL_OPTION: model,
30
- ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: "Qwen 3.8 Max (1M Context)",
31
- ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION: "QwenProxy model qwen3.8-max - 1M context window",
30
+ ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: formatModelDisplayName(model),
31
+ ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION: `QwenProxy ${model}`,
32
32
  ANTHROPIC_DEFAULT_SONNET_MODEL: model,
33
33
  ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.7-plus",
34
34
  ANTHROPIC_DEFAULT_OPUS_MODEL: model,
package/src/sync/codex.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function updateTopLevelKey(content: string, key: string, value: string | number): string {
7
7
  // Find first section header [section]
@@ -75,8 +75,8 @@ experimental_bearer_token = "${apiKey}"
75
75
  catalog.models.unshift({
76
76
  ...template,
77
77
  slug: model,
78
- display_name: `Qwen (${model})`,
79
- description: `QwenProxy ${model} with 1,000,000 token context window`,
78
+ display_name: formatModelDisplayName(model),
79
+ description: `QwenProxy ${model}`,
80
80
  context_window: 1000000,
81
81
  max_context_window: 1000000,
82
82
  });
package/src/sync/index.ts CHANGED
@@ -462,6 +462,9 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
462
462
  const apiKey = resolveApiKey(options.apiKey, config.apiKey);
463
463
  const { anthropicBaseUrl, openaiBaseUrl } = resolveBaseUrls(port, host);
464
464
  const stateFilePath = options.stateFilePath || getDefaultStateFilePath();
465
+ const selectedModel = options.model || "qwen3.8-max";
466
+ const allModels = options.models && options.models.length > 0 ? options.models : undefined;
467
+ const syncModels = options.syncAllModels !== false && allModels ? allModels : [selectedModel];
465
468
 
466
469
  const results: SyncAllResult = {
467
470
  apiKey,
@@ -483,6 +486,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
483
486
  filePath: paths.claudeCode,
484
487
  apiKey,
485
488
  baseUrl: anthropicBaseUrl,
489
+ model: selectedModel,
486
490
  });
487
491
  results.clients.claudeCode = claudeRes;
488
492
  if (claudeRes.success && claudeRes.backupPath) {
@@ -503,6 +507,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
503
507
  apiKey,
504
508
  baseUrl: openaiBaseUrl,
505
509
  setActive: options.setActive ?? true,
510
+ model: selectedModel,
506
511
  });
507
512
  results.clients.codex = codexRes;
508
513
  if (codexRes.success && codexRes.backupPath) {
@@ -522,6 +527,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
522
527
  filePath: paths.openCode,
523
528
  apiKey,
524
529
  baseUrl: openaiBaseUrl,
530
+ model: selectedModel,
531
+ models: syncModels,
525
532
  });
526
533
  results.clients.openCode = openCodeRes;
527
534
  if (openCodeRes.success && openCodeRes.backupPath) {
@@ -541,6 +548,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
541
548
  filePath: paths.omp,
542
549
  apiKey,
543
550
  baseUrl: openaiBaseUrl,
551
+ model: selectedModel,
552
+ models: syncModels,
544
553
  });
545
554
  results.clients.omp = ompRes;
546
555
  if (ompRes.success && ompRes.backupPath) {
@@ -560,6 +569,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
560
569
  filePath: paths.hermes,
561
570
  apiKey,
562
571
  baseUrl: openaiBaseUrl,
572
+ model: selectedModel,
563
573
  });
564
574
  results.clients.hermes = hermesRes;
565
575
  if (hermesRes.success && hermesRes.backupPath) {
@@ -579,6 +589,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
579
589
  filePath: paths.openClaw,
580
590
  apiKey,
581
591
  baseUrl: openaiBaseUrl,
592
+ model: selectedModel,
593
+ models: syncModels,
582
594
  });
583
595
  results.clients.openClaw = openClawRes;
584
596
  if (openClawRes.success && openClawRes.backupPath) {
@@ -599,6 +611,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
599
611
  apiKey,
600
612
  baseUrl: openaiBaseUrl,
601
613
  setActive: options.setActive ?? true,
614
+ model: selectedModel,
615
+ models: syncModels,
602
616
  });
603
617
  results.clients.kilo = kiloRes;
604
618
  if (kiloRes.success && kiloRes.backupPath) {
@@ -618,6 +632,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
618
632
  filePath: paths.cline,
619
633
  apiKey,
620
634
  baseUrl: openaiBaseUrl,
635
+ model: selectedModel,
621
636
  });
622
637
  results.clients.cline = clineRes;
623
638
  if (clineRes.success && clineRes.backupPath) {
@@ -638,6 +653,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
638
653
  apiKey,
639
654
  baseUrl: openaiBaseUrl,
640
655
  setActive: options.setActive ?? true,
656
+ model: selectedModel,
657
+ models: syncModels,
641
658
  });
642
659
  results.clients.zed = zedRes;
643
660
  if (zedRes.success && zedRes.backupPath) {
@@ -657,6 +674,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
657
674
  filePath: paths.aider,
658
675
  apiKey,
659
676
  baseUrl: openaiBaseUrl,
677
+ model: selectedModel,
660
678
  });
661
679
  results.clients.aider = aiderRes;
662
680
  if (aiderRes.success && aiderRes.backupPath) {
package/src/sync/kilo.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function findKeyObjectSpan(content: string, key: string): { start: number; end: number; hasTrailingComma: boolean } | null {
7
7
  const regex = new RegExp(`"${key}"\\s*:\\s*\\{`);
@@ -90,21 +90,16 @@ function buildKiloProviderObject(
90
90
  baseUrl: string,
91
91
  apiKey: string,
92
92
  primaryModel: string = "qwen3.8-max",
93
+ models?: string[],
93
94
  ): Record<string, any> {
94
95
  const modelsObj: Record<string, any> = {};
95
- const modelList = [primaryModel];
96
- if (primaryModel !== "qwen3.7-plus") {
97
- modelList.push("qwen3.7-plus");
98
- }
96
+ const modelList = Array.from(
97
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
98
+ );
99
99
 
100
100
  for (const m of modelList) {
101
101
  modelsObj[m] = {
102
- name:
103
- m === "qwen3.8-max"
104
- ? "Qwen 3.8 Max"
105
- : m === "qwen3.7-plus"
106
- ? "Qwen 3.7 Plus"
107
- : m,
102
+ name: formatModelDisplayName(m),
108
103
  limit: { context: 1048576, output: 65536 },
109
104
  modalities: { input: ["text", "image"], output: ["text"] },
110
105
  reasoning: true,
@@ -128,7 +123,7 @@ function buildKiloProviderObject(
128
123
  }
129
124
 
130
125
  export function syncKilo(options: SyncOptions): ClientSyncResult {
131
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max", setActive = true } = options;
126
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models, setActive = true } = options;
132
127
  try {
133
128
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
134
129
 
@@ -140,7 +135,7 @@ export function syncKilo(options: SyncOptions): ClientSyncResult {
140
135
  content = fs.readFileSync(filePath, "utf-8");
141
136
  }
142
137
 
143
- const providerObj = buildKiloProviderObject(baseUrl, apiKey, model);
138
+ const providerObj = buildKiloProviderObject(baseUrl, apiKey, model, models);
144
139
  const providerJson = JSON.stringify(providerObj, null, 6)
145
140
  .split("\n")
146
141
  .map((line, idx) => (idx === 0 ? line : ` ${line}`))
package/src/sync/omp.ts CHANGED
@@ -1,22 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function buildOmpProviderYaml(
7
7
  baseUrl: string,
8
8
  apiKey: string,
9
9
  primaryModel: string = "qwen3.8-max",
10
+ models?: string[],
10
11
  ): string {
11
- const modelList = [primaryModel];
12
- if (primaryModel !== "qwen3.7-plus") {
13
- modelList.push("qwen3.7-plus");
14
- }
15
-
12
+ const modelList = Array.from(
13
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
14
+ );
16
15
  const formattedModels = modelList
17
16
  .map(
18
17
  (m) => ` - id: ${m}
19
- name: ${m === "qwen3.8-max" ? "Qwen3.8-Max" : m === "qwen3.7-plus" ? "Qwen3.7-Plus" : m}
18
+ name: ${formatModelDisplayName(m).replace(/\s+/g, "")}
20
19
  input: [text, image]
21
20
  contextWindow: 1000000
22
21
  maxTokens: 131072
@@ -41,7 +40,7 @@ ${formattedModels}
41
40
  }
42
41
 
43
42
  export function syncOmp(options: SyncOptions): ClientSyncResult {
44
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max" } = options;
43
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models } = options;
45
44
  try {
46
45
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
46
 
@@ -53,8 +52,7 @@ export function syncOmp(options: SyncOptions): ClientSyncResult {
53
52
  content = fs.readFileSync(filePath, "utf-8");
54
53
  }
55
54
 
56
- const providerBlock = buildOmpProviderYaml(baseUrl, apiKey, model);
57
-
55
+ const providerBlock = buildOmpProviderYaml(baseUrl, apiKey, model, models);
58
56
  if (!content.trim()) {
59
57
  content = `providers:\n${providerBlock}`;
60
58
  } else {
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function findKeyObjectSpan(content: string, key: string): { start: number; end: number; hasTrailingComma: boolean } | null {
7
7
  const regex = new RegExp(`"${key}"\\s*:\\s*\\{`);
@@ -90,41 +90,31 @@ function buildOpenClawProviderObject(
90
90
  baseUrl: string,
91
91
  apiKey: string,
92
92
  model: string = "qwen3.8-max",
93
+ models?: string[],
93
94
  ): Record<string, any> {
94
- const models = [
95
- {
96
- id: model,
97
- name: model === "qwen3.8-max" ? "Qwen 3.8 Max" : model,
98
- reasoning: true,
99
- supportsReasoningEffort: true,
100
- supportedReasoningEfforts: ["low", "medium", "high"],
101
- contextWindow: 1000000,
102
- maxTokens: 65536,
103
- },
104
- ];
105
-
106
- if (model !== "qwen3.7-plus") {
107
- models.push({
108
- id: "qwen3.7-plus",
109
- name: "Qwen 3.7 Plus",
110
- reasoning: true,
111
- supportsReasoningEffort: true,
112
- supportedReasoningEfforts: ["low", "medium", "high"],
113
- contextWindow: 1000000,
114
- maxTokens: 65536,
115
- });
116
- }
95
+ const modelList = Array.from(
96
+ new Set([model, ...(models && models.length > 0 ? models : [model, "qwen3.7-plus"])].filter(Boolean)),
97
+ );
98
+ const modelEntries = modelList.map((m) => ({
99
+ id: m,
100
+ name: formatModelDisplayName(m),
101
+ reasoning: true,
102
+ supportsReasoningEffort: true,
103
+ supportedReasoningEfforts: ["low", "medium", "high"],
104
+ contextWindow: 1000000,
105
+ maxTokens: 65536,
106
+ }));
117
107
 
118
108
  return {
119
109
  baseUrl,
120
110
  apiKey,
121
111
  api: "openai-completions",
122
- models,
112
+ models: modelEntries,
123
113
  };
124
114
  }
125
115
 
126
116
  export function syncOpenClaw(options: SyncOptions): ClientSyncResult {
127
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max", reasoningEffort = "high" } = options;
117
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models, reasoningEffort = "high" } = options;
128
118
  try {
129
119
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
130
120
 
@@ -136,7 +126,7 @@ export function syncOpenClaw(options: SyncOptions): ClientSyncResult {
136
126
  content = fs.readFileSync(filePath, "utf-8");
137
127
  }
138
128
 
139
- const providerObj = buildOpenClawProviderObject(baseUrl, apiKey, model);
129
+ const providerObj = buildOpenClawProviderObject(baseUrl, apiKey, model, models);
140
130
  const providerJson = JSON.stringify(providerObj, null, 6)
141
131
  .split("\n")
142
132
  .map((line, idx) => (idx === 0 ? line : ` ${line}`))
@@ -1,27 +1,22 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
- import { createTimestampBackup, restoreFromBackup } from "./utils.ts";
4
+ import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
5
5
 
6
6
  function buildOpenCodeProviderObject(
7
7
  baseUrl: string,
8
8
  apiKey: string,
9
9
  primaryModel: string = "qwen3.8-max",
10
+ models?: string[],
10
11
  ): Record<string, any> {
11
12
  const modelsObj: Record<string, any> = {};
12
- const modelList = [primaryModel];
13
- if (primaryModel !== "qwen3.7-plus") {
14
- modelList.push("qwen3.7-plus");
15
- }
13
+ const modelList = Array.from(
14
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
15
+ );
16
16
 
17
17
  for (const m of modelList) {
18
18
  modelsObj[m] = {
19
- name:
20
- m === "qwen3.8-max"
21
- ? "Qwen 3.8 Max"
22
- : m === "qwen3.7-plus"
23
- ? "Qwen 3.7 Plus"
24
- : m,
19
+ name: formatModelDisplayName(m),
25
20
  limit: { context: 1048576, output: 65536 },
26
21
  modalities: { input: ["text", "image"], output: ["text"] },
27
22
  reasoning: true,
@@ -127,8 +122,9 @@ function findKeyObjectSpan(content: string, key: string): { start: number; end:
127
122
  }
128
123
 
129
124
  export function syncOpenCode(options: SyncOptions): ClientSyncResult {
130
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max" } = options;
125
+ const { filePath, apiKey, baseUrl, model = "qwen3.8-max", models } = options;
131
126
  try {
127
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
132
128
  let backupPath: string | undefined;
133
129
  let content = "";
134
130
 
@@ -137,7 +133,7 @@ export function syncOpenCode(options: SyncOptions): ClientSyncResult {
137
133
  content = fs.readFileSync(filePath, "utf-8");
138
134
  }
139
135
 
140
- const providerObj = buildOpenCodeProviderObject(baseUrl, apiKey, model);
136
+ const providerObj = buildOpenCodeProviderObject(baseUrl, apiKey, model, models);
141
137
  const providerJson = JSON.stringify(providerObj, null, 6)
142
138
  .split("\n")
143
139
  .map((line, idx) => (idx === 0 ? line : ` ${line}`))
package/src/sync/types.ts CHANGED
@@ -26,6 +26,7 @@ export interface SyncOptions {
26
26
  apiKey: string;
27
27
  baseUrl: string;
28
28
  model?: string;
29
+ models?: string[];
29
30
  setActive?: boolean;
30
31
  reasoningEffort?: "low" | "medium" | "high" | "none";
31
32
  modelSettingsPath?: string;
@@ -38,6 +39,9 @@ export interface SyncAllOptions {
38
39
  setActive?: boolean;
39
40
  stateFilePath?: string;
40
41
  targets?: SyncClientName[];
42
+ model?: string;
43
+ models?: string[];
44
+ syncAllModels?: boolean;
41
45
  customPaths?: {
42
46
  claudeCode?: string;
43
47
  codex?: string;
package/src/sync/utils.ts CHANGED
@@ -54,3 +54,21 @@ export function restoreFromBackup(filePath: string, backupPath?: string): boolea
54
54
  }
55
55
  return true;
56
56
  }
57
+
58
+ /**
59
+ * Format internal model slug to clean human-readable display name without bulky suffixes.
60
+ * E.g. "qwen3.8-max" -> "Qwen 3.8 Max", "qwen3.8-omni-flash" -> "Qwen 3.8 Omni Flash"
61
+ */
62
+ export function formatModelDisplayName(model: string): string {
63
+ if (model === "qwen3.8-max") return "Qwen 3.8 Max";
64
+ if (model === "qwen3.8-omni-flash") return "Qwen 3.8 Omni Flash";
65
+ if (model === "qwen3.7-plus") return "Qwen 3.7 Plus";
66
+ if (model === "qwen3.6-plus") return "Qwen 3.6 Plus";
67
+ return model
68
+ .replace(/^qwen/i, "Qwen ")
69
+ .split(/[-_]/)
70
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
71
+ .join(" ")
72
+ .replace(/\s+/g, " ")
73
+ .trim();
74
+ }
package/src/sync/zed.ts CHANGED
@@ -64,10 +64,15 @@ function parseJsonWithComments(raw: string): Record<string, any> {
64
64
  return JSON.parse(cleaned);
65
65
  }
66
66
 
67
- function buildZedAvailableModels(primaryModel: string = "qwen3.8-max"): any[] {
68
- const models = [
69
- {
70
- name: primaryModel,
67
+ function buildZedAvailableModels(primaryModel: string = "qwen3.8-max", models?: string[]): any[] {
68
+ const modelList = Array.from(
69
+ new Set([primaryModel, ...(models && models.length > 0 ? models : [primaryModel, "qwen3.7-plus"])].filter(Boolean)),
70
+ );
71
+ const zedModels: any[] = [];
72
+
73
+ for (const m of modelList) {
74
+ zedModels.push({
75
+ name: m,
71
76
  max_tokens: 1000000,
72
77
  max_output_tokens: 131072,
73
78
  max_completion_tokens: 131072,
@@ -79,59 +84,46 @@ function buildZedAvailableModels(primaryModel: string = "qwen3.8-max"): any[] {
79
84
  chat_completions: true,
80
85
  interleaved_reasoning: true,
81
86
  },
82
- },
83
- {
84
- name: `${primaryModel}-thinking`,
85
- max_tokens: 1000000,
86
- max_output_tokens: 131072,
87
- max_completion_tokens: 131072,
88
- capabilities: {
89
- tools: true,
90
- images: true,
91
- parallel_tool_calls: true,
92
- prompt_cache_key: true,
93
- chat_completions: true,
94
- interleaved_reasoning: true,
95
- },
96
- },
97
- {
98
- name: `${primaryModel}-fast`,
99
- max_tokens: 1000000,
100
- max_output_tokens: 131072,
101
- max_completion_tokens: 131072,
102
- capabilities: {
103
- tools: true,
104
- images: true,
105
- parallel_tool_calls: true,
106
- prompt_cache_key: true,
107
- chat_completions: true,
108
- interleaved_reasoning: true,
109
- },
110
- },
111
- ];
112
-
113
- if (primaryModel !== "qwen3.7-plus") {
114
- models.push({
115
- name: "qwen3.7-plus",
116
- max_tokens: 1000000,
117
- max_output_tokens: 65536,
118
- max_completion_tokens: 65536,
119
- capabilities: {
120
- tools: true,
121
- images: true,
122
- parallel_tool_calls: true,
123
- prompt_cache_key: true,
124
- chat_completions: true,
125
- interleaved_reasoning: true,
126
- },
127
87
  });
88
+ if (m === primaryModel) {
89
+ zedModels.push(
90
+ {
91
+ name: `${m}-thinking`,
92
+ max_tokens: 1000000,
93
+ max_output_tokens: 131072,
94
+ max_completion_tokens: 131072,
95
+ capabilities: {
96
+ tools: true,
97
+ images: true,
98
+ parallel_tool_calls: true,
99
+ prompt_cache_key: true,
100
+ chat_completions: true,
101
+ interleaved_reasoning: true,
102
+ },
103
+ },
104
+ {
105
+ name: `${m}-fast`,
106
+ max_tokens: 1000000,
107
+ max_output_tokens: 131072,
108
+ max_completion_tokens: 131072,
109
+ capabilities: {
110
+ tools: true,
111
+ images: true,
112
+ parallel_tool_calls: true,
113
+ prompt_cache_key: true,
114
+ chat_completions: true,
115
+ interleaved_reasoning: true,
116
+ },
117
+ },
118
+ );
119
+ }
128
120
  }
129
-
130
- return models;
121
+ return zedModels;
131
122
  }
132
123
 
124
+
133
125
  export function syncZed(options: SyncOptions): ClientSyncResult {
134
- const { filePath, baseUrl, model = "qwen3.8-max", setActive = true } = options;
126
+ const { filePath, baseUrl, model = "qwen3.8-max", models, setActive = true } = options;
135
127
  try {
136
128
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
137
129
 
@@ -153,7 +145,7 @@ export function syncZed(options: SyncOptions): ClientSyncResult {
153
145
 
154
146
  openaiCompatible.QwenProxy = {
155
147
  api_url: baseUrl,
156
- available_models: buildZedAvailableModels(model),
148
+ available_models: buildZedAvailableModels(model, models),
157
149
  };
158
150
 
159
151
  const updatedSettings: Record<string, any> = {
@@ -19,6 +19,7 @@ function parseArgs() {
19
19
  host?: string;
20
20
  setActive: boolean;
21
21
  targets: SyncClientName[];
22
+ model?: string;
22
23
  } = {
23
24
  restore: false,
24
25
  list: false,
@@ -43,6 +44,8 @@ function parseArgs() {
43
44
  options.host = args[++i];
44
45
  } else if (arg === "--no-active") {
45
46
  options.setActive = false;
47
+ } else if ((arg === "--model" || arg === "-m") && args[i + 1]) {
48
+ options.model = args[++i];
46
49
  } else if (arg === "--client" && args[i + 1]) {
47
50
  const normalized = normalizeClientName(args[++i]);
48
51
  if (normalized) options.targets.push(normalized);
@@ -78,6 +81,7 @@ Exemplos:
78
81
 
79
82
  Opções:
80
83
  --client <nome> Nome do cliente (hermes, opencode, claude, openclaw, kilo, cline, omp, codex, zed, aider)
84
+ --model <modelo> Modelo padrão a configurar (padrão: qwen3.8-max)
81
85
  --api-key <chave> Sobrescrever chave de API (padrão: lê do .env ou usa sk-qwenproxy-local)
82
86
  --port <porta> Sobrescrever porta do servidor (padrão: lê do .env ou usa 7936)
83
87
  --host <host> Sobrescrever host do servidor (padrão: 127.0.0.1)
@@ -165,6 +169,7 @@ async function main() {
165
169
  host: options.host,
166
170
  setActive: options.setActive,
167
171
  targets: options.targets.length > 0 ? options.targets : undefined,
172
+ model: options.model,
168
173
  });
169
174
 
170
175
  console.log(`🔑 Chave API: ${result.apiKey}`);
@@ -6,7 +6,6 @@
6
6
  import { config } from "../core/config.ts";
7
7
  import { startServer, stopServer } from "../api/server.ts";
8
8
  import { stripAnsi } from "./theme.ts";
9
- import { recordServerLog } from "../core/server-log-buffer.ts";
10
9
 
11
10
  export type ServerLifecycleState = "offline" | "warming" | "online" | "error";
12
11
  export interface ServerLogEntry {
@@ -137,9 +136,6 @@ export class ServerManager {
137
136
  if (this.logBuffer.length > 2000) {
138
137
  this.logBuffer.shift();
139
138
  }
140
- try {
141
- recordServerLog(level, line);
142
- } catch {}
143
139
  }
144
140
  }
145
141
  public interceptLogs(): void {
@@ -9,7 +9,7 @@ import type { KeyEvent } from "../screen.ts";
9
9
  import { theme, drawBox, stringWidth, truncate, pad, stripAnsi, setClipboardText } from "../theme.ts";
10
10
  import { ServerManager } from "../server-manager.ts";
11
11
  import { loadTuiSettings, saveTuiSettings } from "../settings.ts";
12
- import { getServerLogFilePath } from "../../core/paths.ts";
12
+ import { getServerLogFilePath, isRunningUnderNodeTest } from "../../core/paths.ts";
13
13
  export class LogsView implements TuiView {
14
14
  public readonly id = "logs";
15
15
  public readonly title = "Logs";
@@ -70,8 +70,8 @@ export class LogsView implements TuiView {
70
70
  chips.push({
71
71
  id: d.id,
72
72
  label: d.label,
73
- startCol: startCol - (i === 0 ? 1 : 0),
74
- endCol: endCol + 1,
73
+ startCol,
74
+ endCol,
75
75
  });
76
76
  currentCol += w;
77
77
  }
@@ -90,9 +90,9 @@ export class LogsView implements TuiView {
90
90
  }
91
91
 
92
92
  public handleKey(key: KeyEvent): boolean | void {
93
- // Mouse hover or click on chips (only compute chips when mouse is on chip rows 3 to 6)
93
+ // Mouse hover or click on chips (terminal row 4 is the top border of the logs box where chips are rendered)
94
94
  if ((key.name === "hover" || key.name === "click") && key.mouse) {
95
- if (key.mouse.row === 3) {
95
+ if (key.mouse.row === 4) {
96
96
  const rawCount = ServerManager.getInstance().getLogEntries(this.filter).length;
97
97
  const { chips } = this.getChips(rawCount);
98
98
  const col = key.mouse.col;
@@ -148,7 +148,7 @@ export class LogsView implements TuiView {
148
148
  return (
149
149
  col >= this.lastWidth - 3 &&
150
150
  col <= this.lastWidth &&
151
- row >= 6 &&
151
+ row >= 7 &&
152
152
  row <= 6 + this.lastVisibleCapacity
153
153
  );
154
154
  };
@@ -166,7 +166,7 @@ export class LogsView implements TuiView {
166
166
  if (key.name === "click" && key.mouse && isMouseOnScrollbar(key.mouse.col, key.mouse.row)) {
167
167
  if (this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
168
168
  this.isDraggingScrollbar = true;
169
- const r = key.mouse.row - 6;
169
+ const r = key.mouse.row - 7;
170
170
  const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
171
171
  const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
172
172
  this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
@@ -178,7 +178,7 @@ export class LogsView implements TuiView {
178
178
  // Scrollbar Drag (Hold and Move)
179
179
  if (key.name === "drag" && key.mouse) {
180
180
  if (this.isDraggingScrollbar && this.lastMaxOffset > 0 && this.lastVisibleCapacity > 0) {
181
- const r = key.mouse.row - 6;
181
+ const r = key.mouse.row - 7;
182
182
  const pct = Math.max(0, Math.min(1, r / Math.max(1, this.lastVisibleCapacity - 1)));
183
183
  const targetScrollFromTop = Math.round(pct * this.lastMaxOffset);
184
184
  this.scrollOffset = Math.max(0, Math.min(this.lastMaxOffset, this.lastMaxOffset - targetScrollFromTop));
@@ -195,9 +195,9 @@ export class LogsView implements TuiView {
195
195
  }
196
196
  }
197
197
 
198
- // Mouse click on log rows (terminal row 6+, accounting for 2-line top margin: row 4 & 5 are margin)
199
- if (key.name === "click" && key.mouse && key.mouse.row >= 6) {
200
- const rowOffset = key.mouse.row - 6;
198
+ // Mouse click on log rows (terminal row 7+, accounting for 2-line top margin: row 5 & 6 are margin)
199
+ if (key.name === "click" && key.mouse && key.mouse.row >= 7) {
200
+ const rowOffset = key.mouse.row - 7;
201
201
  if (rowOffset >= 0 && rowOffset < this.lastVisibleCount) {
202
202
  const clickedIdx = this.lastStartIndex + rowOffset;
203
203
  if (this.selectedLogIndex === clickedIdx) {
@@ -471,7 +471,7 @@ export class LogsView implements TuiView {
471
471
  text = `[${entry.time}] [${entry.level}] ${entry.message}`;
472
472
  } else {
473
473
  // For full copy of "all" filter, prefer the complete persistent file from disk if available
474
- if (this.filter === "all") {
474
+ if (this.filter === "all" && !isRunningUnderNodeTest()) {
475
475
  try {
476
476
  const logPath = getServerLogFilePath();
477
477
  if (fs.existsSync(logPath)) {
@@ -299,6 +299,9 @@ export class SyncView implements TuiView {
299
299
  try {
300
300
  const res = syncAllClients({
301
301
  targets: selectedTargets,
302
+ model: currentModel,
303
+ models: this.availableModels,
304
+ syncAllModels: this.syncAllModels,
302
305
  });
303
306
 
304
307
  let successCount = 0;