qwenproxy-cli 1.2.10 → 1.3.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/src/sync/codex.ts CHANGED
@@ -1,7 +1,34 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import os from "node:os";
3
4
  import type { ClientSyncResult, SyncOptions } from "./types.ts";
4
5
  import { createTimestampBackup, restoreFromBackup, formatModelDisplayName } from "./utils.ts";
6
+ import { isRunningUnderNodeTest } from "../core/paths.ts";
7
+
8
+ export function removeTomlSection(content: string, sectionHeader: string): string {
9
+ const lines = content.split(/\r?\n/);
10
+ const result: string[] = [];
11
+ let inTargetSection = false;
12
+ const targetNorm = sectionHeader.replace(/\s+/g, "");
13
+
14
+ for (const line of lines) {
15
+ const trimmed = line.trim();
16
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
17
+ const headerInside = trimmed.slice(1, -1).trim().replace(/\s+/g, "");
18
+ if (headerInside === targetNorm) {
19
+ inTargetSection = true;
20
+ continue;
21
+ } else {
22
+ inTargetSection = false;
23
+ }
24
+ }
25
+ if (!inTargetSection) {
26
+ result.push(line);
27
+ }
28
+ }
29
+
30
+ return result.join("\n").replace(/\n{3,}/g, "\n\n").trim();
31
+ }
5
32
 
6
33
  function updateTopLevelKey(content: string, key: string, value: string | number): string {
7
34
  // Find first section header [section]
@@ -22,74 +49,108 @@ function updateTopLevelKey(content: string, key: string, value: string | number)
22
49
  return newTopPart + restPart;
23
50
  }
24
51
 
25
- export function syncCodex(options: SyncOptions): ClientSyncResult {
26
- const { filePath, apiKey, baseUrl, model = "qwen3.8-max", setActive = true } = options;
27
- try {
28
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
52
+ function syncCodexSingleFile(
53
+ targetPath: string,
54
+ options: SyncOptions,
55
+ ): { backupPath?: string } {
56
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
29
57
 
30
- let backupPath: string | undefined;
31
- let content = "";
58
+ let backupPath: string | undefined;
59
+ let content = "";
32
60
 
33
- if (fs.existsSync(filePath)) {
34
- backupPath = createTimestampBackup(filePath);
35
- content = fs.readFileSync(filePath, "utf-8");
36
- }
61
+ if (fs.existsSync(targetPath)) {
62
+ backupPath = createTimestampBackup(targetPath);
63
+ content = fs.readFileSync(targetPath, "utf-8");
64
+ }
37
65
 
38
- // Build the qwenproxy model_provider block
39
- const providerBlock = `[model_providers.qwenproxy]
66
+ const { apiKey, baseUrl, model = "qwen3.8-max", setActive = true } = options;
67
+
68
+ // Build the qwenproxy model_provider block
69
+ const providerBlock = `[model_providers.qwenproxy]
40
70
  name = "QwenProxy"
41
71
  base_url = "${baseUrl}"
42
72
  wire_api = "responses"
43
73
  experimental_bearer_token = "${apiKey}"
44
74
  `;
45
75
 
46
- // Replace or append [model_providers.qwenproxy]
47
- const providerRegex = /\[model_providers\.qwenproxy\][\s\S]*?(?=(?:^\[|\Z))/m;
48
- if (providerRegex.test(content)) {
49
- content = content.replace(providerRegex, providerBlock);
50
- } else {
51
- content = content.trimEnd() + (content.length > 0 ? "\n\n" : "") + providerBlock;
52
- }
76
+ // Clean any previous or duplicate [model_providers.qwenproxy] blocks to guarantee zero duplicates
77
+ content = removeTomlSection(content, "model_providers.qwenproxy");
78
+ content = content.trimEnd() + (content.length > 0 ? "\n\n" : "") + providerBlock;
53
79
 
54
- // Set active model if requested
55
- if (setActive) {
56
- content = updateTopLevelKey(content, "model", model);
57
- content = updateTopLevelKey(content, "model_provider", "qwenproxy");
58
- content = updateTopLevelKey(content, "model_context_window", 1000000);
59
- }
80
+ // Set active model if requested
81
+ if (setActive) {
82
+ content = updateTopLevelKey(content, "model", model);
83
+ content = updateTopLevelKey(content, "model_provider", "qwenproxy");
84
+ content = updateTopLevelKey(content, "model_context_window", 1000000);
85
+ }
60
86
 
61
- // If a custom model_catalog_json is configured, ensure the target model is listed in it
62
- const catalogMatch = content.match(/^model_catalog_json\s*=\s*["']([^"']+)["']/m);
63
- if (catalogMatch && catalogMatch[1]) {
64
- try {
65
- const rawCatalogPath = catalogMatch[1];
66
- const catalogPath = path.isAbsolute(rawCatalogPath)
67
- ? rawCatalogPath
68
- : path.resolve(path.dirname(filePath), rawCatalogPath);
69
- if (fs.existsSync(catalogPath)) {
70
- const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8"));
71
- if (Array.isArray(catalog.models)) {
72
- const hasModel = catalog.models.some((m: any) => m?.slug === model);
73
- if (!hasModel) {
74
- const template = catalog.models[0] || {};
75
- catalog.models.unshift({
76
- ...template,
77
- slug: model,
78
- display_name: formatModelDisplayName(model),
79
- description: `QwenProxy ${model}`,
80
- context_window: 1000000,
81
- max_context_window: 1000000,
82
- });
83
- fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2), "utf-8");
84
- }
87
+ // If a custom model_catalog_json is configured, ensure the target model is listed in it
88
+ const catalogMatch = content.match(/^model_catalog_json\s*=\s*["']([^"']+)["']/m);
89
+ if (catalogMatch && catalogMatch[1]) {
90
+ try {
91
+ const rawCatalogPath = catalogMatch[1];
92
+ const catalogPath = path.isAbsolute(rawCatalogPath)
93
+ ? rawCatalogPath
94
+ : path.resolve(path.dirname(targetPath), rawCatalogPath);
95
+ if (fs.existsSync(catalogPath)) {
96
+ const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8"));
97
+ if (Array.isArray(catalog.models)) {
98
+ const hasModel = catalog.models.some((m: any) => m?.slug === model);
99
+ if (!hasModel) {
100
+ const template = catalog.models[0] || {};
101
+ catalog.models.unshift({
102
+ ...template,
103
+ slug: model,
104
+ display_name: formatModelDisplayName(model),
105
+ description: `QwenProxy ${model}`,
106
+ context_window: 1000000,
107
+ max_context_window: 1000000,
108
+ });
109
+ fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2), "utf-8");
85
110
  }
86
111
  }
87
- } catch {
88
- // Non-fatal catalog enhancement
89
112
  }
113
+ } catch {
114
+ // Non-fatal catalog enhancement
90
115
  }
116
+ }
117
+
118
+ fs.writeFileSync(targetPath, content.trimEnd() + "\n", "utf-8");
119
+ return { backupPath };
120
+ }
91
121
 
92
- fs.writeFileSync(filePath, content.trimEnd() + "\n", "utf-8");
122
+ export function syncCodex(options: SyncOptions): ClientSyncResult {
123
+ const { filePath, baseUrl } = options;
124
+ try {
125
+ const { backupPath } = syncCodexSingleFile(filePath, options);
126
+
127
+ // If an alternate Codex config exists (e.g. ~/.codex/config.toml vs CODEX_HOME/config.toml),
128
+ // sync it as well so both standard CLI and custom launcher environments are always synchronized.
129
+ const home = os.homedir();
130
+ const standardPath = path.join(home, ".codex", "config.toml");
131
+ const codexHomePath = process.env.CODEX_HOME
132
+ ? path.join(process.env.CODEX_HOME, "config.toml")
133
+ : undefined;
134
+
135
+ const isStandard =
136
+ path.resolve(filePath).toLowerCase() === path.resolve(standardPath).toLowerCase();
137
+ const isCodexHome =
138
+ Boolean(codexHomePath) &&
139
+ path.resolve(filePath).toLowerCase() === path.resolve(codexHomePath!).toLowerCase();
140
+
141
+ if ((isStandard || isCodexHome) && !isRunningUnderNodeTest()) {
142
+ const alternatePath = isStandard ? codexHomePath : standardPath;
143
+ if (
144
+ alternatePath &&
145
+ path.resolve(alternatePath).toLowerCase() !== path.resolve(filePath).toLowerCase()
146
+ ) {
147
+ try {
148
+ if (fs.existsSync(alternatePath) || fs.existsSync(path.dirname(alternatePath))) {
149
+ syncCodexSingleFile(alternatePath, options);
150
+ }
151
+ } catch {}
152
+ }
153
+ }
93
154
 
94
155
  return {
95
156
  client: "codex",
@@ -116,20 +177,48 @@ export function restoreCodex(filePath: string, backupPath?: string): ClientSyncR
116
177
  // If backup was restored but still had qwenproxy (or if no backup was found),
117
178
  // strip the QwenProxy provider block cleanly so the file is guaranteed un-synced.
118
179
  let manuallyCleaned = false;
119
- if (fs.existsSync(filePath)) {
120
- try {
121
- let content = fs.readFileSync(filePath, "utf-8");
122
- if (content.includes("[model_providers.qwenproxy]") || /^model_provider\s*=\s*["']qwenproxy["']/m.test(content)) {
123
- const providerRegex = /\[model_providers\.qwenproxy\][\s\S]*?(?=(?:^\[|\Z))/m;
124
- content = content.replace(providerRegex, "").trimEnd();
125
- content = content.replace(/^model_provider\s*=\s*["']qwenproxy["']\r?\n?/m, "");
126
- if (/^model\s*=\s*["']qwen/m.test(content)) {
127
- content = content.replace(/^model\s*=\s*["']qwen[^"']*["']\r?\n?/m, "");
180
+ const cleanSingleFile = (p: string) => {
181
+ if (fs.existsSync(p)) {
182
+ try {
183
+ let content = fs.readFileSync(p, "utf-8");
184
+ if (
185
+ content.includes("[model_providers.qwenproxy]") ||
186
+ /^model_provider\s*=\s*["']qwenproxy["']/m.test(content)
187
+ ) {
188
+ content = removeTomlSection(content, "model_providers.qwenproxy");
189
+ content = content.replace(/^model_provider\s*=\s*["']qwenproxy["']\r?\n?/m, "");
190
+ if (/^model\s*=\s*["']qwen/m.test(content)) {
191
+ content = content.replace(/^model\s*=\s*["']qwen[^"']*["']\r?\n?/m, "");
192
+ }
193
+ fs.writeFileSync(p, content.trimEnd() + "\n", "utf-8");
194
+ manuallyCleaned = true;
128
195
  }
129
- fs.writeFileSync(filePath, content.trimEnd() + "\n", "utf-8");
130
- manuallyCleaned = true;
131
- }
132
- } catch {}
196
+ } catch {}
197
+ }
198
+ };
199
+
200
+ cleanSingleFile(filePath);
201
+
202
+ const home = os.homedir();
203
+ const standardPath = path.join(home, ".codex", "config.toml");
204
+ const codexHomePath = process.env.CODEX_HOME
205
+ ? path.join(process.env.CODEX_HOME, "config.toml")
206
+ : undefined;
207
+
208
+ const isStandard =
209
+ path.resolve(filePath).toLowerCase() === path.resolve(standardPath).toLowerCase();
210
+ const isCodexHome =
211
+ Boolean(codexHomePath) &&
212
+ path.resolve(filePath).toLowerCase() === path.resolve(codexHomePath!).toLowerCase();
213
+
214
+ if ((isStandard || isCodexHome) && !isRunningUnderNodeTest()) {
215
+ const alternatePath = isStandard ? codexHomePath : standardPath;
216
+ if (
217
+ alternatePath &&
218
+ path.resolve(alternatePath).toLowerCase() !== path.resolve(filePath).toLowerCase()
219
+ ) {
220
+ cleanSingleFile(alternatePath);
221
+ }
133
222
  }
134
223
 
135
224
  const success = restoredFromBackup || manuallyCleaned;
package/src/sync/index.ts CHANGED
@@ -64,7 +64,10 @@ export function resolveApiKey(
64
64
  }
65
65
  return overrideKey.trim();
66
66
  }
67
- const envKey = process.env.API_KEY || process.env.ADMIN_PASSWORD || configKey;
67
+ const envKey =
68
+ (!isPlaceholderApiKey(process.env.API_KEY) ? process.env.API_KEY : undefined) ||
69
+ (!isPlaceholderApiKey(process.env.ADMIN_PASSWORD) ? process.env.ADMIN_PASSWORD : undefined) ||
70
+ configKey;
68
71
  if (envKey && !isPlaceholderApiKey(envKey)) {
69
72
  return envKey.trim();
70
73
  }
@@ -165,7 +168,9 @@ export function getDefaultPaths(): {
165
168
 
166
169
  return {
167
170
  claudeCode: path.join(home, ".claude", "settings.json"),
168
- codex: process.env.CODEX_HOME
171
+ codex: fs.existsSync(path.join(home, ".codex", "config.toml"))
172
+ ? path.join(home, ".codex", "config.toml")
173
+ : process.env.CODEX_HOME
169
174
  ? path.join(process.env.CODEX_HOME, "config.toml")
170
175
  : path.join(home, ".codex", "config.toml"),
171
176
  openCode: existingOpenCode || openCodeCandidates[0],
@@ -282,10 +282,16 @@ export class ServerManager {
282
282
  }
283
283
  this.restoreLogs();
284
284
  try {
285
+ await stopServer();
285
286
  this.state = "offline";
286
287
  } catch {}
287
288
  }
288
289
 
290
+ public async restart(): Promise<void> {
291
+ await this.stop();
292
+ await this.ensureStarted();
293
+ }
294
+
289
295
  public startRemoteLogStream(host: string, port: number): void {
290
296
  if (this.remoteLogAbort) {
291
297
  this.remoteLogAbort.abort();
@@ -2,6 +2,7 @@
2
2
  * QwenProxy TUI - Accounts & Cooldowns Management View (Tab 5)
3
3
  */
4
4
 
5
+ import crypto from "crypto";
5
6
  import type { TuiView, ProxyStatusSnapshot } from "../types.ts";
6
7
  import type { KeyEvent } from "../screen.ts";
7
8
  import { theme, glyphs, drawBox, pad, truncate, getClipboardText } from "../theme.ts";
@@ -56,6 +57,7 @@ export class AccountsView implements TuiView {
56
57
  private statusMessageTimer: NodeJS.Timeout | null = null;
57
58
  private isAddModalOpen = false;
58
59
  private isBatchModalOpen = false;
60
+ private isValidatingAccount = false;
59
61
  private addEmailInput = "";
60
62
  private addPasswordInput = "";
61
63
  private addEmailCursor = 0;
@@ -147,6 +149,7 @@ export class AccountsView implements TuiView {
147
149
  }
148
150
 
149
151
  private async saveModalAccount(): Promise<void> {
152
+ if (this.isValidatingAccount) return;
150
153
  const email = this.addEmailInput.trim();
151
154
  const password = this.addPasswordInput.trim();
152
155
  if (!email || !password) {
@@ -154,17 +157,54 @@ export class AccountsView implements TuiView {
154
157
  return;
155
158
  }
156
159
 
160
+ if (process.env.NODE_TEST_CONTEXT) {
161
+ try {
162
+ const newAcc = addAccount(email, password);
163
+ this.isAddModalOpen = false;
164
+ this.addEmailInput = "";
165
+ this.addPasswordInput = "";
166
+ this.addEmailCursor = 0;
167
+ this.addPasswordCursor = 0;
168
+ await this.refresh();
169
+ this.setStatusMessage(theme.green(`✓ Conta ${email} salva! Conectando...`));
170
+ } catch (err: any) {
171
+ this.setStatusMessage(theme.red(`✗ Erro ao salvar: ${err?.message || String(err)}`));
172
+ }
173
+ return;
174
+ }
175
+
176
+ this.isValidatingAccount = true;
177
+ this.setStatusMessage(theme.yellow(`[...] Validando credenciais para ${email}...`));
178
+
179
+ const tempId = crypto.randomUUID();
157
180
  try {
158
- const newAcc = addAccount(email, password);
181
+ const { validateAccountLogin } = await import("../../services/playwright.ts");
182
+ const { wipeAccountSessionFiles } = await import("../../core/accounts.ts");
183
+ const { clearAccountCooldown } = await import("../../core/account-manager.ts");
184
+
185
+ const ok = await validateAccountLogin(
186
+ { id: tempId, email, password },
187
+ config.playwright.headless,
188
+ config.playwright.browser,
189
+ );
190
+
191
+ if (!ok) {
192
+ wipeAccountSessionFiles(tempId);
193
+ clearAccountCooldown(tempId);
194
+ this.setStatusMessage(theme.red(`✗ Falha no login: credenciais inválidas ou bloqueio no Qwen`));
195
+ return;
196
+ }
197
+
198
+ const newAcc = addAccount(email, password, tempId);
159
199
  this.isAddModalOpen = false;
160
200
  this.addEmailInput = "";
161
201
  this.addPasswordInput = "";
162
202
  this.addEmailCursor = 0;
163
203
  this.addPasswordCursor = 0;
164
204
  await this.refresh();
165
- this.setStatusMessage(theme.green(`✓ Conta ${email} salva! Conectando...`));
205
+ this.setStatusMessage(theme.green(`✓ Conta ${email} validada e salva! Conectando...`));
166
206
 
167
- if (process.stdout.isTTY && !process.env.NODE_TEST_CONTEXT) {
207
+ if (process.stdout.isTTY) {
168
208
  const sManager = ServerManager.getInstance();
169
209
  const sState = sManager.getState();
170
210
  if (sState !== "online" && sState !== "warming") {
@@ -189,7 +229,13 @@ export class AccountsView implements TuiView {
189
229
  }
190
230
  }
191
231
  } catch (err: any) {
192
- this.setStatusMessage(theme.red(`✗ Erro ao salvar: ${err?.message || String(err)}`));
232
+ const { wipeAccountSessionFiles } = await import("../../core/accounts.ts");
233
+ const { clearAccountCooldown } = await import("../../core/account-manager.ts");
234
+ wipeAccountSessionFiles(tempId);
235
+ clearAccountCooldown(tempId);
236
+ this.setStatusMessage(theme.red(`✗ Erro ao validar: ${err?.message || String(err)}`));
237
+ } finally {
238
+ this.isValidatingAccount = false;
193
239
  }
194
240
  }
195
241
  private async saveBatchAccounts(): Promise<void> {
@@ -445,6 +491,7 @@ export class AccountsView implements TuiView {
445
491
 
446
492
  // 1. Add Account Modal Active
447
493
  if (this.isAddModalOpen) {
494
+ if (this.isValidatingAccount) return true;
448
495
  if (key.name === "escape") {
449
496
  this.isAddModalOpen = false;
450
497
  this.addEmailInput = "";
@@ -1161,8 +1208,9 @@ export class AccountsView implements TuiView {
1161
1208
  ? (passHover ? theme.bgHover(` ${maskedPass} `) : theme.cyan(` ${maskedPass} `))
1162
1209
  : (passHover ? theme.bgHover(" (digite a senha) ") : theme.muted(" (digite a senha) "));
1163
1210
  }
1164
- const saveBtn =
1165
- this.modalHoveredField === "save"
1211
+ const saveBtn = this.isValidatingAccount
1212
+ ? theme.yellow(" [ Validando... ] ")
1213
+ : this.modalHoveredField === "save"
1166
1214
  ? theme.bgHover(theme.green(" [ Enter ] Salvar "))
1167
1215
  : theme.green("[ Enter ] Salvar");
1168
1216
 
@@ -469,26 +469,10 @@ export class LogsView implements TuiView {
469
469
  if (singleLineOnly && this.selectedLogIndex !== null && rawEntries[this.selectedLogIndex]) {
470
470
  const entry = rawEntries[this.selectedLogIndex];
471
471
  text = `[${entry.time}] [${entry.level}] ${entry.message}`;
472
- } else {
473
- // For full copy of "all" filter, prefer the complete persistent file from disk if available
474
- if (this.filter === "all" && !isRunningUnderNodeTest()) {
475
- try {
476
- const logPath = getServerLogFilePath();
477
- if (fs.existsSync(logPath)) {
478
- const diskContent = fs.readFileSync(logPath, "utf-8");
479
- if (diskContent && diskContent.trim().length > 0) {
480
- text = diskContent.trim();
481
- }
482
- }
483
- } catch {}
484
- }
485
-
486
- // Fallback to memory buffer if disk log is empty or on filtered view
487
- if (!text && rawEntries.length > 0) {
488
- text = rawEntries
489
- .map((entry) => `[${entry.time}] [${entry.level}] ${entry.message}`)
490
- .join("\n");
491
- }
472
+ } else if (rawEntries.length > 0) {
473
+ text = rawEntries
474
+ .map((entry) => `[${entry.time}] [${entry.level}] ${entry.message}`)
475
+ .join("\n");
492
476
  }
493
477
 
494
478
  if (!text) return;