qwenproxy-cli 1.0.32 → 1.2.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.
@@ -1,4 +1,4 @@
1
- import { config, type ChatMode } from "../../core/config.ts";
1
+ import { config, type ChatMode, isStatelessChatMode } from "../../core/config.ts";
2
2
  import { ContextLengthExceededError, ValidationError } from "../../core/errors.ts";
3
3
  import { getModelContextWindow } from "../../core/model-registry.ts";
4
4
  import {
@@ -61,16 +61,16 @@ export async function buildFinalContext(
61
61
 
62
62
  const modelContextWindow = getModelContextWindow(modelId);
63
63
  const useThreadNative = true;
64
- const isTempMode = chatMode === "temp";
64
+ const isStateless = isStatelessChatMode(chatMode);
65
65
  // A continuation is ANY evidence of a prior turn, not just a plain
66
66
  // role:"assistant" message. Tool-loop clients (Zed/Cline) can send history
67
67
  // with tool/function responses or assistant tool_calls but WITHOUT a plain
68
68
  // assistant entry; misclassifying those as a new session forced the FULL
69
69
  // history to be re-sent on every request (and every chat_in_progress retry)
70
70
  // instead of the thread-native delta.
71
- // In temp mode EVERY request is a new (ephemeral) chat, so the whole history
72
- // is always sent and no thread state is ever consulted.
73
- const isNewSession = isTempMode
71
+ // In stateless modes (stateless, stateless-temp, temp) EVERY request is a
72
+ // new chat, so the whole history is always sent and no thread state is consulted.
73
+ const isNewSession = isStateless
74
74
  ? true
75
75
  : !messages.some(isContinuationMessage);
76
76
  const completeInstructions = [systemPrompt.trim(), toolInstructions.trim()]
@@ -83,7 +83,7 @@ export async function buildFinalContext(
83
83
  // OR: this is a continuation (has assistant messages in history)
84
84
  // This prevents new IDE chats from accidentally reusing old Qwen chats
85
85
  // while still allowing continuations without explicit session_id
86
- const allowThreadReuse = isTempMode
86
+ const allowThreadReuse = isStateless
87
87
  ? false
88
88
  : useThreadNative && (hasExplicitConversationKey || !isNewSession); // has assistant messages = continuation of existing chat
89
89
 
@@ -91,7 +91,7 @@ export async function buildFinalContext(
91
91
  // an explicit conversation key. Otherwise, generate an ephemeral ID for
92
92
  // logging/metrics only (not used for thread reuse). Temp mode never persists
93
93
  // a thread, so it has no session id.
94
- const sessionId = isTempMode
94
+ const sessionId = isStateless
95
95
  ? null
96
96
  : (conversationKey || useThreadNative)
97
97
  ? deriveSessionId(
@@ -110,7 +110,7 @@ export async function buildFinalContext(
110
110
  // Thread-native: send full history when Qwen has no context yet, but preserve
111
111
  // tool-result deltas because the upstream parent chain already owns the call.
112
112
  // Temp mode: always send the FULL history (OpenAI standard).
113
- const baseActivePrompt = isTempMode
113
+ const baseActivePrompt = isStateless
114
114
  ? prompt
115
115
  : (!existingThread && !hasTrailingToolResult ? prompt : currentPrompt) ||
116
116
  prompt;
@@ -179,7 +179,7 @@ export async function buildFinalContext(
179
179
  isNewSession,
180
180
  useThreadNative,
181
181
  // Thread state is only persisted in thread mode (temp chats are ephemeral).
182
- updateLogicalThread: isTempMode ? false : useThreadNative,
182
+ updateLogicalThread: isStateless ? false : useThreadNative,
183
183
  chatMode,
184
184
  isThinkingModel,
185
185
  estimatedTokens,
@@ -20,7 +20,7 @@ import {
20
20
  handleChatCompletionsError,
21
21
  type AssistantCompleteEvent,
22
22
  } from "./streaming.ts";
23
- import { config, type ChatMode } from "../../core/config.ts";
23
+ import { config, type ChatMode, normalizeChatMode } from "../../core/config.ts";
24
24
  import { logger } from "../../core/logger.ts";
25
25
  import { metrics } from "../../core/metrics.ts";
26
26
  import { getContextMeterHeaders, type ContextMeterMode } from "../../services/context-meter.ts";
@@ -50,11 +50,20 @@ function formatTimingHeader(timings: Record<string, number>): string {
50
50
  * else silently uses the configured default.
51
51
  */
52
52
  function resolveChatMode(headerValue: string | undefined): ChatMode {
53
- if (headerValue === "thread" || headerValue === "temp" || headerValue === "temp-thread") {
54
- return headerValue;
55
- }
56
- if (headerValue === "temp_thread") {
57
- return "temp-thread";
53
+ if (headerValue) {
54
+ const m = headerValue.trim().toLowerCase().replace(/_/g, "-");
55
+ if (
56
+ m === "thread" ||
57
+ m === "thread-temp" ||
58
+ m === "temp-thread" ||
59
+ m === "stateless" ||
60
+ m === "stateles" ||
61
+ m === "stateless-temp" ||
62
+ m === "stateles-temp" ||
63
+ m === "temp"
64
+ ) {
65
+ return normalizeChatMode(m);
66
+ }
58
67
  }
59
68
  return config.qwen.chatMode;
60
69
  }
@@ -1,6 +1,6 @@
1
1
  import crypto from "crypto";
2
2
  import { getQwenHeaders, isAuthMockEnabled } from "./auth-playwright.ts";
3
- import { config, type ChatMode } from "../core/config.ts";
3
+ import { config, type ChatMode, isLocalChatMode, isStatelessChatMode } from "../core/config.ts";
4
4
  import { logger, isToolcallDebugEnabled } from "../core/logger.ts";
5
5
  import {
6
6
  computeQuotaCooldownMs,
@@ -83,9 +83,8 @@ export function buildChatNewBody(
83
83
  project_id: "",
84
84
  timestamp: Date.now(),
85
85
  chat_type: "t2t",
86
- // thread normal (persisted), temp / temp-thread → local (ephemeral, not listed).
87
- chat_mode:
88
- chatMode === "temp" || chatMode === "temp-thread" ? "local" : "normal",
86
+ // normal: thread & stateless (persisted in Qwen); local: thread-temp & stateless-temp (ephemeral)
87
+ chat_mode: isLocalChatMode(chatMode) ? "local" : "normal",
89
88
  };
90
89
  }
91
90
 
@@ -201,7 +200,7 @@ export async function acquireNewQwenChatSession(
201
200
  accountId?: string,
202
201
  chatMode: ChatMode = "thread",
203
202
  ): Promise<{ chatId: string; leasedFromPool: boolean }> {
204
- if (isQwenChatPoolEnabled() && chatMode !== "temp") {
203
+ if (isQwenChatPoolEnabled() && !isStatelessChatMode(chatMode)) {
205
204
  const key = chatPoolKey(accountId, model);
206
205
  const pooled = precreatedChatSessions.get(key);
207
206
  const chatId = pooled?.shift();
@@ -12,7 +12,7 @@ import {
12
12
  } from "../core/errors.ts";
13
13
  import { buildQwenRequestHeaders } from "./qwen-headers.ts";
14
14
  import { qwenOrigin, qwenUrl } from "./qwen-url.ts";
15
- import { config, type ChatMode } from "../core/config.ts";
15
+ import { config, type ChatMode, isLocalChatMode } from "../core/config.ts";
16
16
  import { logger } from "../core/logger.ts";
17
17
  import { estimateTokenCount } from "../utils/context-truncation.ts";
18
18
  import type {
@@ -440,6 +440,14 @@ const modelsCache = new Map<
440
440
  { models: PublicQwenModel[]; fetchedAt: number }
441
441
  >();
442
442
 
443
+ export function getAnyCachedQwenModels(): PublicQwenModel[] | undefined {
444
+ for (const entry of modelsCache.values()) {
445
+ if (entry.models && entry.models.length > 0) {
446
+ return entry.models;
447
+ }
448
+ }
449
+ return undefined;
450
+ }
443
451
  const nativeToolsDisabled = new Set<string>();
444
452
  const disablingNativeToolsInProgress = new Set<string>();
445
453
  const lastSyncedPersonalizationHashes = new Map<string, string>();
@@ -492,6 +500,25 @@ function setPersonalizationHashInDb(accountId: string, hash: string): void {
492
500
  );
493
501
  }
494
502
  }
503
+ export function clearPersonalizationDbCache(accountId?: string): number {
504
+ try {
505
+ const db = getDatabase();
506
+ if (accountId) {
507
+ const info = db
508
+ .prepare("DELETE FROM personalization_cache WHERE account_id = ?")
509
+ .run(accountId);
510
+ lastSyncedPersonalizationHashes.delete(accountId);
511
+ activePersonalizationByAccount.delete(accountId);
512
+ return info.changes;
513
+ }
514
+ const info = db.prepare("DELETE FROM personalization_cache").run();
515
+ lastSyncedPersonalizationHashes.clear();
516
+ activePersonalizationByAccount.clear();
517
+ return info.changes;
518
+ } catch {
519
+ return 0;
520
+ }
521
+ }
495
522
 
496
523
  function shortContentHash(value: string): string {
497
524
  return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
@@ -666,23 +693,14 @@ export function buildQwenSettingsUpdatePayload(
666
693
  currentSettings: any,
667
694
  instruction: string,
668
695
  ): Record<string, unknown> {
669
- // The real client (HAR networkv2) POSTs ONLY `{personalization: {...}}` to
670
- // /api/v2/users/user/settings/update. Live probes confirmed the personalization
671
- // object accepts the GET-personalization spread + enable_for_new_chat, but the
672
- // FULL-settings spread this used to send (ui/memory/tools_enabled + every GET
673
- // field like tts_speaker_v2, code_settings, manage_cookies) is rejected with
674
- // RequestValidationError. Safe-settings are applied by disableNativeTools as
675
- // their own combined partial POST (probe-accepted). NOTE: the persistent
676
- // RequestValidationError that haunted the sync was NOT the payload — it was a
677
- // missing Content-Type header (attemptPost received the raw getQwenHeaders
678
- // map); the body was not parsed as a JSON object ("Field '': Input should be
679
- // a valid dictionary...").
680
696
  const currentPersonalization =
681
697
  currentSettings?.personalization &&
682
698
  typeof currentSettings.personalization === "object"
683
699
  ? currentSettings.personalization
684
700
  : {};
685
701
 
702
+ const hasInstruction = instruction.trim().length > 0;
703
+
686
704
  return {
687
705
  personalization: {
688
706
  ...currentPersonalization,
@@ -693,7 +711,7 @@ export function buildQwenSettingsUpdatePayload(
693
711
  : currentPersonalization.description,
694
712
  style: null,
695
713
  instruction,
696
- enable_for_new_chat: true,
714
+ enable_for_new_chat: hasInstruction,
697
715
  },
698
716
  };
699
717
  }
@@ -1519,7 +1537,12 @@ export async function syncQwenRequestPersonalization(
1519
1537
  }
1520
1538
 
1521
1539
  // 2. Check DB cache (survives restarts) (skipped on forceSync)
1522
- if (!bypassCache && syncHash && !cachedHash) {
1540
+ const isEmptyInstruction = instruction.trim().length === 0;
1541
+
1542
+ // 2. Check DB cache (survives restarts) (skipped on forceSync)
1543
+ // For empty instructions, do not blindly trust DB cache without verifying
1544
+ // because external agents or web sessions might have altered personalization.
1545
+ if (!bypassCache && syncHash && !cachedHash && !isEmptyInstruction) {
1523
1546
  const dbHash = getPersonalizationHashFromDb(cacheKey);
1524
1547
  if (dbHash === syncHash) {
1525
1548
  lastSyncedPersonalizationHashes.set(cacheKey, syncHash);
@@ -1528,7 +1551,6 @@ export async function syncQwenRequestPersonalization(
1528
1551
  return true;
1529
1552
  }
1530
1553
  }
1531
-
1532
1554
  let existing = { chars: null, bytes: null, hash: null } as ReturnType<
1533
1555
  typeof textSize
1534
1556
  >;
@@ -1544,7 +1566,9 @@ export async function syncQwenRequestPersonalization(
1544
1566
  );
1545
1567
  currentSettings = existingJson?.data ?? null;
1546
1568
  payload = buildQwenSettingsUpdatePayload(currentSettings, instruction);
1547
- existing = textSize(existingJson?.data?.personalization?.instruction);
1569
+ const existingInstruction = existingJson?.data?.personalization?.instruction;
1570
+ const existingEnabled = existingJson?.data?.personalization?.enable_for_new_chat === true;
1571
+ existing = textSize(existingInstruction);
1548
1572
  const existingSafeSettingsApplied =
1549
1573
  existingJson?.data?.ui?.largeTextAsFile === false &&
1550
1574
  existingJson?.data?.ui?.splitLargeChunks === false &&
@@ -1554,8 +1578,12 @@ export async function syncQwenRequestPersonalization(
1554
1578
  existingJson?.data?.memory?.enable_history_memory === false &&
1555
1579
  existingJson?.data?.tools_enabled?.web_search === false &&
1556
1580
  existingJson?.data?.tools_enabled?.code_interpreter === false;
1557
- if (existing.hash === sent.hash && existingSafeSettingsApplied) {
1558
- lastSyncedPersonalizationHashes.set(cacheKey, syncHash);
1581
+ const isEmptyAndCleared =
1582
+ isEmptyInstruction &&
1583
+ (!existingInstruction || existingInstruction.trim().length === 0) &&
1584
+ !existingEnabled;
1585
+ const isContentMatched = existing.hash !== null && existing.hash === sent.hash;
1586
+ if ((isContentMatched || isEmptyAndCleared) && existingSafeSettingsApplied) {
1559
1587
  setPersonalizationHashInDb(cacheKey, syncHash);
1560
1588
  rememberActivePersonalization(
1561
1589
  cacheKey,
@@ -1669,6 +1697,7 @@ export async function syncQwenRequestPersonalization(
1669
1697
  typeof textSize
1670
1698
  >;
1671
1699
 
1700
+ let verifyData: any = null;
1672
1701
  if (config.qwen.personalizationVerifyGet) {
1673
1702
  const { json: verifyJson } =
1674
1703
  await requestQwenPersonalizationInBrowser(
@@ -1677,11 +1706,18 @@ export async function syncQwenRequestPersonalization(
1677
1706
  "/api/v2/users/user/settings",
1678
1707
  requestHeaders,
1679
1708
  );
1680
- stored = textSize(verifyJson?.data?.personalization?.instruction);
1681
- }
1682
-
1683
- const matchReturned = returned.hash !== null && returned.hash === sent.hash;
1684
- const matchStored = stored.hash === null ? null : stored.hash === sent.hash;
1709
+ verifyData = verifyJson?.data?.personalization;
1710
+ stored = textSize(verifyData?.instruction);
1711
+ }
1712
+
1713
+ const matchReturned =
1714
+ (isEmptyInstruction && (!returnedInstruction || returned.chars === 0)) ||
1715
+ (returned.hash !== null && returned.hash === sent.hash);
1716
+ const matchStored =
1717
+ stored.hash === null
1718
+ ? null
1719
+ : (isEmptyInstruction && (!verifyData?.instruction || stored.chars === 0)) ||
1720
+ stored.hash === sent.hash;
1685
1721
  const applied = matchReturned || matchStored === true;
1686
1722
  if (syncHash && applied) {
1687
1723
  lastSyncedPersonalizationHashes.set(cacheKey, syncHash);
@@ -2757,10 +2793,7 @@ async function createQwenStreamInternal(
2757
2793
  chatId: chatSessionId || null,
2758
2794
  parentId: actualParentId ?? "",
2759
2795
  chat_id: chatSessionId || null,
2760
- chat_mode:
2761
- options?.chatMode === "temp" || options?.chatMode === "temp-thread"
2762
- ? "local"
2763
- : "normal",
2796
+ chat_mode: isLocalChatMode(options?.chatMode) ? "local" : "normal",
2764
2797
  model: model,
2765
2798
  parent_id: actualParentId,
2766
2799
  messages: [
@@ -33,12 +33,14 @@ export function syncClaudeCode(options: SyncOptions): ClientSyncResult {
33
33
  ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.7-plus",
34
34
  ANTHROPIC_DEFAULT_OPUS_MODEL: model,
35
35
  CLAUDE_CODE_MAX_CONTEXT_TOKENS: "1000000",
36
+ CLAUDE_CODE_DISABLE_ARTIFACT: "1",
36
37
  };
37
38
 
38
39
  const updatedSettings = {
39
40
  ...existingSettings,
40
41
  env,
41
42
  model,
43
+ enableArtifact: false,
42
44
  };
43
45
 
44
46
  fs.writeFileSync(filePath, JSON.stringify(updatedSettings, null, 2) + "\n", "utf-8");
@@ -63,13 +65,48 @@ export function syncClaudeCode(options: SyncOptions): ClientSyncResult {
63
65
  }
64
66
 
65
67
  export function restoreClaudeCode(filePath: string, backupPath?: string): ClientSyncResult {
66
- const restored = restoreFromBackup(filePath, backupPath);
68
+ const restoredFromBackup = restoreFromBackup(filePath, backupPath);
69
+
70
+ let manuallyCleaned = false;
71
+ if (fs.existsSync(filePath)) {
72
+ try {
73
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
74
+ if (data.env && (data.env.ANTHROPIC_BASE_URL?.includes("7936") || data.env.ANTHROPIC_AUTH_TOKEN === "sk-qwenproxy-local" || data.env.ANTHROPIC_MODEL?.includes("qwen"))) {
75
+ delete data.env.ANTHROPIC_BASE_URL;
76
+ delete data.env.ANTHROPIC_AUTH_TOKEN;
77
+ delete data.env.ANTHROPIC_MODEL;
78
+ delete data.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
79
+ delete data.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
80
+ delete data.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
81
+ delete data.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
82
+ delete data.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
83
+ delete data.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
84
+ delete data.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
85
+ delete data.env.CLAUDE_CODE_DISABLE_ARTIFACT;
86
+ delete data.enableArtifact;
87
+ if (data.model && data.model.toLowerCase().includes("qwen")) {
88
+ delete data.model;
89
+ }
90
+ if (Object.keys(data.env).length === 0) {
91
+ delete data.env;
92
+ }
93
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
94
+ manuallyCleaned = true;
95
+ }
96
+ } catch {}
97
+ }
98
+
99
+ const success = restoredFromBackup || manuallyCleaned;
67
100
  return {
68
101
  client: "claude-code",
69
102
  filePath,
70
103
  backupPath,
71
- success: restored,
72
- action: restored ? "restored" : "failed",
73
- message: restored ? "Restored Claude Code settings from backup" : "Backup file not found",
104
+ success,
105
+ action: success ? "restored" : "failed",
106
+ message: success
107
+ ? restoredFromBackup
108
+ ? "Restored Claude Code settings from backup"
109
+ : "Removed QwenProxy configuration from Claude Code settings"
110
+ : "Backup file not found",
74
111
  };
75
112
  }
package/src/sync/cline.ts CHANGED
@@ -98,13 +98,43 @@ export function syncCline(options: SyncOptions): ClientSyncResult {
98
98
  }
99
99
 
100
100
  export function restoreCline(filePath: string, backupPath?: string): ClientSyncResult {
101
- const restored = restoreFromBackup(filePath, backupPath);
101
+ const restoredFromBackup = restoreFromBackup(filePath, backupPath);
102
+
103
+ let manuallyCleaned = false;
104
+ if (fs.existsSync(filePath)) {
105
+ try {
106
+ const db = new Database(filePath);
107
+ const rows = db
108
+ .prepare("SELECT key, value FROM ItemTable WHERE key = 'saoudrizwan.claude-dev' OR key = 'ZooCodeOrganization.zoo-code'")
109
+ .all() as Array<{ key: string; value: string }>;
110
+
111
+ for (const row of rows) {
112
+ try {
113
+ const parsed = JSON.parse(row.value);
114
+ if (parsed.openAiBaseUrl?.includes("7936") || parsed.openAiModelId?.includes("qwen")) {
115
+ delete parsed.openAiBaseUrl;
116
+ delete parsed.openAiApiKey;
117
+ delete parsed.openAiModelId;
118
+ db.prepare("UPDATE ItemTable SET value = ? WHERE key = ?").run(JSON.stringify(parsed), row.key);
119
+ manuallyCleaned = true;
120
+ }
121
+ } catch {}
122
+ }
123
+ db.close();
124
+ } catch {}
125
+ }
126
+
127
+ const success = restoredFromBackup || manuallyCleaned;
102
128
  return {
103
129
  client: "cline",
104
130
  filePath,
105
131
  backupPath,
106
- success: restored,
107
- action: restored ? "restored" : "failed",
108
- message: restored ? "Restored Cline settings from backup" : "Backup file not found",
132
+ success,
133
+ action: success ? "restored" : "failed",
134
+ message: success
135
+ ? restoredFromBackup
136
+ ? "Restored Cline settings from backup"
137
+ : "Removed QwenProxy configuration from Cline settings"
138
+ : "Backup file not found",
109
139
  };
110
140
  }
package/src/sync/codex.ts CHANGED
@@ -111,13 +111,38 @@ experimental_bearer_token = "${apiKey}"
111
111
  }
112
112
 
113
113
  export function restoreCodex(filePath: string, backupPath?: string): ClientSyncResult {
114
- const restored = restoreFromBackup(filePath, backupPath);
114
+ const restoredFromBackup = restoreFromBackup(filePath, backupPath);
115
+
116
+ // If backup was restored but still had qwenproxy (or if no backup was found),
117
+ // strip the QwenProxy provider block cleanly so the file is guaranteed un-synced.
118
+ 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, "");
128
+ }
129
+ fs.writeFileSync(filePath, content.trimEnd() + "\n", "utf-8");
130
+ manuallyCleaned = true;
131
+ }
132
+ } catch {}
133
+ }
134
+
135
+ const success = restoredFromBackup || manuallyCleaned;
115
136
  return {
116
137
  client: "codex",
117
138
  filePath,
118
139
  backupPath,
119
- success: restored,
120
- action: restored ? "restored" : "failed",
121
- message: restored ? "Restored Codex config from backup" : "Backup file not found",
140
+ success,
141
+ action: success ? "restored" : "failed",
142
+ message: success
143
+ ? restoredFromBackup
144
+ ? "Restored Codex config from backup"
145
+ : "Removed QwenProxy configuration from Codex config"
146
+ : "Backup file not found",
122
147
  };
123
148
  }