qwenproxy-cli 1.0.0 → 1.0.2
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/LICENSE +14 -14
- package/README.md +906 -906
- package/bin/qwenproxy.js +5 -1
- package/package.json +77 -78
- package/src/api/error-classifier.ts +159 -159
- package/src/api/error-helpers.ts +118 -118
- package/src/api/models.ts +261 -261
- package/src/api/server.ts +860 -859
- package/src/cache/memory-cache.ts +385 -385
- package/src/clean-cache.ts +204 -204
- package/src/core/account-concurrency.ts +671 -671
- package/src/core/account-manager.ts +301 -297
- package/src/core/account-priority.ts +163 -163
- package/src/core/accounts.ts +186 -186
- package/src/core/config.ts +383 -383
- package/src/core/crypto-utils.ts +79 -79
- package/src/core/database.ts +276 -276
- package/src/core/errors.ts +118 -118
- package/src/core/logger.ts +269 -269
- package/src/core/memory-usage.ts +84 -84
- package/src/core/metrics.ts +291 -291
- package/src/core/model-alias.ts +77 -77
- package/src/core/model-registry.ts +544 -544
- package/src/core/mutex.ts +119 -119
- package/src/core/paths.ts +199 -199
- package/src/core/prompt-limits.ts +214 -214
- package/src/core/reasoning-effort.ts +102 -102
- package/src/core/stream-registry.ts +96 -96
- package/src/core/waf-isolation.ts +117 -117
- package/src/core/watchdog.ts +195 -195
- package/src/delete-chats.ts +23 -23
- package/src/index.ts +65 -64
- package/src/login.ts +147 -147
- package/src/reset-cooldowns.ts +11 -11
- package/src/routes/anthropic/index.ts +355 -355
- package/src/routes/anthropic/translate.ts +522 -522
- package/src/routes/anthropic/types.ts +154 -154
- package/src/routes/anthropic/validation.ts +144 -144
- package/src/routes/chat/account.ts +1817 -1817
- package/src/routes/chat/context.ts +241 -241
- package/src/routes/chat/errors.ts +85 -85
- package/src/routes/chat/helpers.ts +268 -268
- package/src/routes/chat/index.ts +618 -618
- package/src/routes/chat/media.ts +285 -285
- package/src/routes/chat/retry-policy.ts +754 -754
- package/src/routes/chat/stop.ts +98 -98
- package/src/routes/chat/streaming.ts +2710 -2710
- package/src/routes/chat/validation.ts +526 -526
- package/src/routes/chat.ts +2 -2
- package/src/routes/completions.ts +290 -290
- package/src/routes/images.ts +139 -139
- package/src/routes/responses/adapter.ts +503 -503
- package/src/routes/responses/index.ts +405 -405
- package/src/routes/responses/state.ts +230 -230
- package/src/routes/responses/streaming.ts +528 -528
- package/src/routes/responses/types.ts +285 -285
- package/src/routes/responses/validation.ts +202 -202
- package/src/routes/upload.ts +731 -731
- package/src/routes/videos.ts +214 -214
- package/src/services/auth-playwright.ts +173 -173
- package/src/services/captcha-coordinator.ts +161 -161
- package/src/services/captcha-solver.ts +553 -553
- package/src/services/chat-cleanup.ts +80 -80
- package/src/services/context-meter.ts +317 -317
- package/src/services/fingerprint.ts +242 -242
- package/src/services/human-behavior.ts +173 -173
- package/src/services/media-generation.ts +1748 -1748
- package/src/services/playwright.ts +2878 -2800
- package/src/services/qwen-chat-pool.ts +345 -345
- package/src/services/qwen-errors.ts +133 -133
- package/src/services/qwen-headers.ts +79 -79
- package/src/services/qwen-thread-state.ts +393 -393
- package/src/services/qwen-url.ts +19 -19
- package/src/services/qwen.ts +3126 -3126
- package/src/services/session-keeper.ts +88 -88
- package/src/services/token-estimation-metrics.ts +118 -118
- package/src/sync/claude-code.ts +75 -75
- package/src/sync/codex.ts +123 -123
- package/src/sync/index.ts +362 -362
- package/src/sync/omp.ts +105 -105
- package/src/sync/opencode.ts +214 -214
- package/src/sync/types.ts +53 -53
- package/src/sync/utils.ts +27 -27
- package/src/sync-clients.ts +189 -189
- package/src/tools/instructions.ts +137 -137
- package/src/tools/manifest.ts +81 -81
- package/src/tools/parser.ts +2989 -2989
- package/src/tools/toolcall-tags.ts +142 -142
- package/src/tui/app.ts +259 -264
- package/src/tui/index.ts +61 -61
- package/src/tui/markdown.ts +258 -258
- package/src/tui/proxy-client.ts +331 -326
- package/src/tui/screen.ts +294 -278
- package/src/tui/server-manager.ts +270 -270
- package/src/tui/theme.ts +432 -432
- package/src/tui/types.ts +33 -33
- package/src/tui/views/accounts-view.ts +656 -656
- package/src/tui/views/chat-view.ts +1018 -823
- package/src/tui/views/logs-view.ts +479 -413
- package/src/tui/views/status-view.ts +204 -204
- package/src/tui/views/storage-view.ts +304 -291
- package/src/tui/views/sync-view.ts +409 -409
- package/src/types/ali-oss.d.ts +32 -32
- package/src/update-cli.ts +121 -0
- package/src/utils/context-truncation.ts +84 -84
- package/src/utils/json.ts +380 -380
- package/src/utils/session-id.ts +37 -37
- package/src/utils/tool-call-guard.ts +84 -84
- package/src/utils/types.ts +109 -109
|
@@ -1,241 +1,241 @@
|
|
|
1
|
-
import { config, type ChatMode } from "../../core/config.ts";
|
|
2
|
-
import { ContextLengthExceededError, ValidationError } from "../../core/errors.ts";
|
|
3
|
-
import { getModelContextWindow } from "../../core/model-registry.ts";
|
|
4
|
-
import {
|
|
5
|
-
assertPromptWithinLimits,
|
|
6
|
-
isRequestPersonalizationWithinLimit,
|
|
7
|
-
} from "../../core/prompt-limits.ts";
|
|
8
|
-
import type { Message } from "../../utils/types.ts";
|
|
9
|
-
import { estimateTokenCount } from "../../utils/context-truncation.ts";
|
|
10
|
-
import { deriveSessionId } from "../../utils/session-id.ts";
|
|
11
|
-
import { getLogicalThreadState, consumeToolCapNotice } from "../../services/qwen.ts";
|
|
12
|
-
|
|
13
|
-
export { estimateTokenCount, getModelContextWindow, deriveSessionId };
|
|
14
|
-
|
|
15
|
-
export interface FinalContext {
|
|
16
|
-
finalPrompt: string;
|
|
17
|
-
sessionId: string | null;
|
|
18
|
-
existingThread: boolean;
|
|
19
|
-
shouldResetUpstreamThread: boolean;
|
|
20
|
-
isNewSession: boolean;
|
|
21
|
-
useThreadNative: boolean;
|
|
22
|
-
updateLogicalThread: boolean;
|
|
23
|
-
chatMode: ChatMode;
|
|
24
|
-
isThinkingModel: boolean;
|
|
25
|
-
estimatedTokens: number;
|
|
26
|
-
modelContextWindow: number;
|
|
27
|
-
isTitleGenerationRequest: boolean;
|
|
28
|
-
requestPersonalizationInstruction: string | null;
|
|
29
|
-
hasExplicitConversationKey: boolean;
|
|
30
|
-
allowThreadReuse: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface BuildContextParams {
|
|
34
|
-
messages: Message[];
|
|
35
|
-
systemPrompt: string;
|
|
36
|
-
toolInstructions: string;
|
|
37
|
-
prompt: string;
|
|
38
|
-
currentPrompt: string;
|
|
39
|
-
modelId: string;
|
|
40
|
-
enableThinking: boolean;
|
|
41
|
-
conversationKey: string | null;
|
|
42
|
-
hasExplicitConversationKey: boolean;
|
|
43
|
-
chatMode?: ChatMode;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function buildFinalContext(
|
|
47
|
-
params: BuildContextParams,
|
|
48
|
-
): Promise<FinalContext> {
|
|
49
|
-
const {
|
|
50
|
-
messages,
|
|
51
|
-
systemPrompt,
|
|
52
|
-
toolInstructions,
|
|
53
|
-
prompt,
|
|
54
|
-
currentPrompt,
|
|
55
|
-
modelId,
|
|
56
|
-
enableThinking,
|
|
57
|
-
conversationKey,
|
|
58
|
-
hasExplicitConversationKey,
|
|
59
|
-
chatMode = "thread",
|
|
60
|
-
} = params;
|
|
61
|
-
|
|
62
|
-
const modelContextWindow = getModelContextWindow(modelId);
|
|
63
|
-
const useThreadNative = true;
|
|
64
|
-
const isTempMode = chatMode === "temp";
|
|
65
|
-
// A continuation is ANY evidence of a prior turn, not just a plain
|
|
66
|
-
// role:"assistant" message. Tool-loop clients (Zed/Cline) can send history
|
|
67
|
-
// with tool/function responses or assistant tool_calls but WITHOUT a plain
|
|
68
|
-
// assistant entry; misclassifying those as a new session forced the FULL
|
|
69
|
-
// history to be re-sent on every request (and every chat_in_progress retry)
|
|
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
|
|
74
|
-
? true
|
|
75
|
-
: !messages.some(isContinuationMessage);
|
|
76
|
-
const completeInstructions = [systemPrompt.trim(), toolInstructions.trim()]
|
|
77
|
-
.filter(Boolean)
|
|
78
|
-
.join("\n\n");
|
|
79
|
-
|
|
80
|
-
// Thread reuse is allowed when:
|
|
81
|
-
// 1. Thread-native mode is active
|
|
82
|
-
// 2. Either: explicit session_id/conversation_id was provided
|
|
83
|
-
// OR: this is a continuation (has assistant messages in history)
|
|
84
|
-
// This prevents new IDE chats from accidentally reusing old Qwen chats
|
|
85
|
-
// while still allowing continuations without explicit session_id
|
|
86
|
-
const allowThreadReuse = isTempMode
|
|
87
|
-
? false
|
|
88
|
-
: useThreadNative && (hasExplicitConversationKey || !isNewSession); // has assistant messages = continuation of existing chat
|
|
89
|
-
|
|
90
|
-
// Compute sessionId: only generate a persistent session ID when we have
|
|
91
|
-
// an explicit conversation key. Otherwise, generate an ephemeral ID for
|
|
92
|
-
// logging/metrics only (not used for thread reuse). Temp mode never persists
|
|
93
|
-
// a thread, so it has no session id.
|
|
94
|
-
const sessionId = isTempMode
|
|
95
|
-
? null
|
|
96
|
-
: (conversationKey || useThreadNative)
|
|
97
|
-
? deriveSessionId(
|
|
98
|
-
messages,
|
|
99
|
-
conversationKey ? completeInstructions : "",
|
|
100
|
-
conversationKey ?? "implicit-thread",
|
|
101
|
-
)
|
|
102
|
-
: null;
|
|
103
|
-
|
|
104
|
-
// Only load existing thread when reuse is allowed
|
|
105
|
-
const existingThread = allowThreadReuse
|
|
106
|
-
? getLogicalThreadState(sessionId)
|
|
107
|
-
: null;
|
|
108
|
-
|
|
109
|
-
const hasTrailingToolResult = detectTrailingToolResult(messages);
|
|
110
|
-
// Thread-native: send full history when Qwen has no context yet, but preserve
|
|
111
|
-
// tool-result deltas because the upstream parent chain already owns the call.
|
|
112
|
-
// Temp mode: always send the FULL history (OpenAI standard).
|
|
113
|
-
const baseActivePrompt = isTempMode
|
|
114
|
-
? prompt
|
|
115
|
-
: (!existingThread && !hasTrailingToolResult ? prompt : currentPrompt) ||
|
|
116
|
-
prompt;
|
|
117
|
-
|
|
118
|
-
// If the previous turn of this session was closed early at the per-turn
|
|
119
|
-
// tool-call cap, tell the model so it knows calls beyond the cap were NOT
|
|
120
|
-
// executed and can re-issue them. The notice is consumed once (it clears
|
|
121
|
-
// itself) and rides this single turn only. This is a transient system notice,
|
|
122
|
-
// not the persistent personalization instruction, so it may live in the prompt.
|
|
123
|
-
const toolCapNotice = consumeToolCapNotice(sessionId)
|
|
124
|
-
? `[SYSTEM NOTICE] Your previous response reached the maximum of ${config.retry.maxToolCallsPerTurn} tool calls per turn; any tool calls beyond that limit were NOT executed. Review the tool results below and continue; if you intended more operations, issue them now in smaller batches.\n\n`
|
|
125
|
-
: "";
|
|
126
|
-
const activePrompt = toolCapNotice
|
|
127
|
-
? toolCapNotice + baseActivePrompt
|
|
128
|
-
: baseActivePrompt;
|
|
129
|
-
const isTitleGenerationRequest = detectTitleGenerationRequest(messages);
|
|
130
|
-
const requestedPersonalization =
|
|
131
|
-
config.qwen.personalizationFromRequest && !isTitleGenerationRequest;
|
|
132
|
-
const personalizationInstruction = completeInstructions;
|
|
133
|
-
const useRequestPersonalization =
|
|
134
|
-
requestedPersonalization &&
|
|
135
|
-
isRequestPersonalizationWithinLimit(personalizationInstruction);
|
|
136
|
-
|
|
137
|
-
// Agent instructions and tools ride ONLY the account-level personalization
|
|
138
|
-
// (confirmed before the completion request is sent — the real Qwen client
|
|
139
|
-
// also never sends a system prompt in the completions payload). When the
|
|
140
|
-
// channel cannot carry them, fail loud instead of degrading to inline.
|
|
141
|
-
if (completeInstructions && !useRequestPersonalization) {
|
|
142
|
-
if (requestedPersonalization) {
|
|
143
|
-
throw new ContextLengthExceededError(
|
|
144
|
-
`System instructions and tools (${Buffer.byteLength(personalizationInstruction, "utf8")} bytes) exceed the personalization payload limit (${config.qwen.maxPersonalizationBytes} bytes) and are no longer sent inline. Raise QWEN_MAX_PERSONALIZATION_BYTES or reduce the instruction size.`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
if (!isTitleGenerationRequest) {
|
|
148
|
-
throw new ValidationError(
|
|
149
|
-
"Agent instructions can only be delivered via Qwen account personalization, but QWEN_PERSONALIZATION_FROM_REQUEST is disabled. Re-enable it or remove the system instructions from the request.",
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
const estimatedTokens = estimateTokenCount(
|
|
154
|
-
completeInstructions,
|
|
155
|
-
activePrompt,
|
|
156
|
-
);
|
|
157
|
-
// Instructions are delivered exclusively via account-level personalization;
|
|
158
|
-
// the prompt carries only the conversation. Title generation does not sync
|
|
159
|
-
// personalization, so it keeps its (small) instructions inline.
|
|
160
|
-
const finalPrompt =
|
|
161
|
-
isTitleGenerationRequest && completeInstructions
|
|
162
|
-
? `${completeInstructions}\n${activePrompt}`
|
|
163
|
-
: activePrompt;
|
|
164
|
-
|
|
165
|
-
// Truncation is deferred to tryCreateStreamWithRetry, which runs after the
|
|
166
|
-
// account is selected and the real model context window has been synced from
|
|
167
|
-
// Qwen's /api/models catalog. The early context build only performs the byte
|
|
168
|
-
// limit check; the authoritative token check happens downstream.
|
|
169
|
-
assertPromptWithinLimits(finalPrompt, modelId, { checkModelContext: false });
|
|
170
|
-
|
|
171
|
-
const isThinkingModel = enableThinking;
|
|
172
|
-
const shouldResetUpstreamThread = false;
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
finalPrompt,
|
|
176
|
-
sessionId,
|
|
177
|
-
existingThread: !!existingThread,
|
|
178
|
-
shouldResetUpstreamThread,
|
|
179
|
-
isNewSession,
|
|
180
|
-
useThreadNative,
|
|
181
|
-
// Thread state is only persisted in thread mode (temp chats are ephemeral).
|
|
182
|
-
updateLogicalThread: isTempMode ? false : useThreadNative,
|
|
183
|
-
chatMode,
|
|
184
|
-
isThinkingModel,
|
|
185
|
-
estimatedTokens,
|
|
186
|
-
modelContextWindow,
|
|
187
|
-
isTitleGenerationRequest,
|
|
188
|
-
requestPersonalizationInstruction: useRequestPersonalization
|
|
189
|
-
? personalizationInstruction
|
|
190
|
-
: null,
|
|
191
|
-
hasExplicitConversationKey,
|
|
192
|
-
allowThreadReuse,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function isContinuationMessage(message: Message): boolean {
|
|
197
|
-
return (
|
|
198
|
-
message.role === "assistant" ||
|
|
199
|
-
message.role === "tool" ||
|
|
200
|
-
message.role === "function" ||
|
|
201
|
-
(Array.isArray(message.tool_calls) && message.tool_calls.length > 0)
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function extractMessageText(message: Message | undefined): string {
|
|
206
|
-
if (!message) return "";
|
|
207
|
-
const content: unknown = message.content;
|
|
208
|
-
if (typeof content === "string") return content;
|
|
209
|
-
if (Array.isArray(content)) {
|
|
210
|
-
return content
|
|
211
|
-
.map((part: any) => (part?.type === "text" ? part.text || "" : ""))
|
|
212
|
-
.join("\n");
|
|
213
|
-
}
|
|
214
|
-
if (content && typeof content === "object") return JSON.stringify(content);
|
|
215
|
-
return "";
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function detectTrailingToolResult(messages: Message[]): boolean {
|
|
219
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
220
|
-
const role = messages[i].role;
|
|
221
|
-
if (role === "system") continue;
|
|
222
|
-
return role === "tool" || role === "function";
|
|
223
|
-
}
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function detectTitleGenerationRequest(messages: Message[]): boolean {
|
|
228
|
-
if (messages.length < 2) return false;
|
|
229
|
-
const last = messages[messages.length - 1];
|
|
230
|
-
if (last?.role !== "user") return false;
|
|
231
|
-
|
|
232
|
-
const text = extractMessageText(last).toLowerCase();
|
|
233
|
-
if (!text) return false;
|
|
234
|
-
|
|
235
|
-
// The first pattern is strictly subsumed by the second (which does not
|
|
236
|
-
// require the leading verb), so it is redundant and was removed.
|
|
237
|
-
return (
|
|
238
|
-
/\btitle\b[\s\S]{0,80}\bconversation\b/.test(text) ||
|
|
239
|
-
/\bconversation\b[\s\S]{0,80}\btitle\b/.test(text)
|
|
240
|
-
);
|
|
241
|
-
}
|
|
1
|
+
import { config, type ChatMode } from "../../core/config.ts";
|
|
2
|
+
import { ContextLengthExceededError, ValidationError } from "../../core/errors.ts";
|
|
3
|
+
import { getModelContextWindow } from "../../core/model-registry.ts";
|
|
4
|
+
import {
|
|
5
|
+
assertPromptWithinLimits,
|
|
6
|
+
isRequestPersonalizationWithinLimit,
|
|
7
|
+
} from "../../core/prompt-limits.ts";
|
|
8
|
+
import type { Message } from "../../utils/types.ts";
|
|
9
|
+
import { estimateTokenCount } from "../../utils/context-truncation.ts";
|
|
10
|
+
import { deriveSessionId } from "../../utils/session-id.ts";
|
|
11
|
+
import { getLogicalThreadState, consumeToolCapNotice } from "../../services/qwen.ts";
|
|
12
|
+
|
|
13
|
+
export { estimateTokenCount, getModelContextWindow, deriveSessionId };
|
|
14
|
+
|
|
15
|
+
export interface FinalContext {
|
|
16
|
+
finalPrompt: string;
|
|
17
|
+
sessionId: string | null;
|
|
18
|
+
existingThread: boolean;
|
|
19
|
+
shouldResetUpstreamThread: boolean;
|
|
20
|
+
isNewSession: boolean;
|
|
21
|
+
useThreadNative: boolean;
|
|
22
|
+
updateLogicalThread: boolean;
|
|
23
|
+
chatMode: ChatMode;
|
|
24
|
+
isThinkingModel: boolean;
|
|
25
|
+
estimatedTokens: number;
|
|
26
|
+
modelContextWindow: number;
|
|
27
|
+
isTitleGenerationRequest: boolean;
|
|
28
|
+
requestPersonalizationInstruction: string | null;
|
|
29
|
+
hasExplicitConversationKey: boolean;
|
|
30
|
+
allowThreadReuse: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildContextParams {
|
|
34
|
+
messages: Message[];
|
|
35
|
+
systemPrompt: string;
|
|
36
|
+
toolInstructions: string;
|
|
37
|
+
prompt: string;
|
|
38
|
+
currentPrompt: string;
|
|
39
|
+
modelId: string;
|
|
40
|
+
enableThinking: boolean;
|
|
41
|
+
conversationKey: string | null;
|
|
42
|
+
hasExplicitConversationKey: boolean;
|
|
43
|
+
chatMode?: ChatMode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function buildFinalContext(
|
|
47
|
+
params: BuildContextParams,
|
|
48
|
+
): Promise<FinalContext> {
|
|
49
|
+
const {
|
|
50
|
+
messages,
|
|
51
|
+
systemPrompt,
|
|
52
|
+
toolInstructions,
|
|
53
|
+
prompt,
|
|
54
|
+
currentPrompt,
|
|
55
|
+
modelId,
|
|
56
|
+
enableThinking,
|
|
57
|
+
conversationKey,
|
|
58
|
+
hasExplicitConversationKey,
|
|
59
|
+
chatMode = "thread",
|
|
60
|
+
} = params;
|
|
61
|
+
|
|
62
|
+
const modelContextWindow = getModelContextWindow(modelId);
|
|
63
|
+
const useThreadNative = true;
|
|
64
|
+
const isTempMode = chatMode === "temp";
|
|
65
|
+
// A continuation is ANY evidence of a prior turn, not just a plain
|
|
66
|
+
// role:"assistant" message. Tool-loop clients (Zed/Cline) can send history
|
|
67
|
+
// with tool/function responses or assistant tool_calls but WITHOUT a plain
|
|
68
|
+
// assistant entry; misclassifying those as a new session forced the FULL
|
|
69
|
+
// history to be re-sent on every request (and every chat_in_progress retry)
|
|
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
|
|
74
|
+
? true
|
|
75
|
+
: !messages.some(isContinuationMessage);
|
|
76
|
+
const completeInstructions = [systemPrompt.trim(), toolInstructions.trim()]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join("\n\n");
|
|
79
|
+
|
|
80
|
+
// Thread reuse is allowed when:
|
|
81
|
+
// 1. Thread-native mode is active
|
|
82
|
+
// 2. Either: explicit session_id/conversation_id was provided
|
|
83
|
+
// OR: this is a continuation (has assistant messages in history)
|
|
84
|
+
// This prevents new IDE chats from accidentally reusing old Qwen chats
|
|
85
|
+
// while still allowing continuations without explicit session_id
|
|
86
|
+
const allowThreadReuse = isTempMode
|
|
87
|
+
? false
|
|
88
|
+
: useThreadNative && (hasExplicitConversationKey || !isNewSession); // has assistant messages = continuation of existing chat
|
|
89
|
+
|
|
90
|
+
// Compute sessionId: only generate a persistent session ID when we have
|
|
91
|
+
// an explicit conversation key. Otherwise, generate an ephemeral ID for
|
|
92
|
+
// logging/metrics only (not used for thread reuse). Temp mode never persists
|
|
93
|
+
// a thread, so it has no session id.
|
|
94
|
+
const sessionId = isTempMode
|
|
95
|
+
? null
|
|
96
|
+
: (conversationKey || useThreadNative)
|
|
97
|
+
? deriveSessionId(
|
|
98
|
+
messages,
|
|
99
|
+
conversationKey ? completeInstructions : "",
|
|
100
|
+
conversationKey ?? "implicit-thread",
|
|
101
|
+
)
|
|
102
|
+
: null;
|
|
103
|
+
|
|
104
|
+
// Only load existing thread when reuse is allowed
|
|
105
|
+
const existingThread = allowThreadReuse
|
|
106
|
+
? getLogicalThreadState(sessionId)
|
|
107
|
+
: null;
|
|
108
|
+
|
|
109
|
+
const hasTrailingToolResult = detectTrailingToolResult(messages);
|
|
110
|
+
// Thread-native: send full history when Qwen has no context yet, but preserve
|
|
111
|
+
// tool-result deltas because the upstream parent chain already owns the call.
|
|
112
|
+
// Temp mode: always send the FULL history (OpenAI standard).
|
|
113
|
+
const baseActivePrompt = isTempMode
|
|
114
|
+
? prompt
|
|
115
|
+
: (!existingThread && !hasTrailingToolResult ? prompt : currentPrompt) ||
|
|
116
|
+
prompt;
|
|
117
|
+
|
|
118
|
+
// If the previous turn of this session was closed early at the per-turn
|
|
119
|
+
// tool-call cap, tell the model so it knows calls beyond the cap were NOT
|
|
120
|
+
// executed and can re-issue them. The notice is consumed once (it clears
|
|
121
|
+
// itself) and rides this single turn only. This is a transient system notice,
|
|
122
|
+
// not the persistent personalization instruction, so it may live in the prompt.
|
|
123
|
+
const toolCapNotice = consumeToolCapNotice(sessionId)
|
|
124
|
+
? `[SYSTEM NOTICE] Your previous response reached the maximum of ${config.retry.maxToolCallsPerTurn} tool calls per turn; any tool calls beyond that limit were NOT executed. Review the tool results below and continue; if you intended more operations, issue them now in smaller batches.\n\n`
|
|
125
|
+
: "";
|
|
126
|
+
const activePrompt = toolCapNotice
|
|
127
|
+
? toolCapNotice + baseActivePrompt
|
|
128
|
+
: baseActivePrompt;
|
|
129
|
+
const isTitleGenerationRequest = detectTitleGenerationRequest(messages);
|
|
130
|
+
const requestedPersonalization =
|
|
131
|
+
config.qwen.personalizationFromRequest && !isTitleGenerationRequest;
|
|
132
|
+
const personalizationInstruction = completeInstructions;
|
|
133
|
+
const useRequestPersonalization =
|
|
134
|
+
requestedPersonalization &&
|
|
135
|
+
isRequestPersonalizationWithinLimit(personalizationInstruction);
|
|
136
|
+
|
|
137
|
+
// Agent instructions and tools ride ONLY the account-level personalization
|
|
138
|
+
// (confirmed before the completion request is sent — the real Qwen client
|
|
139
|
+
// also never sends a system prompt in the completions payload). When the
|
|
140
|
+
// channel cannot carry them, fail loud instead of degrading to inline.
|
|
141
|
+
if (completeInstructions && !useRequestPersonalization) {
|
|
142
|
+
if (requestedPersonalization) {
|
|
143
|
+
throw new ContextLengthExceededError(
|
|
144
|
+
`System instructions and tools (${Buffer.byteLength(personalizationInstruction, "utf8")} bytes) exceed the personalization payload limit (${config.qwen.maxPersonalizationBytes} bytes) and are no longer sent inline. Raise QWEN_MAX_PERSONALIZATION_BYTES or reduce the instruction size.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!isTitleGenerationRequest) {
|
|
148
|
+
throw new ValidationError(
|
|
149
|
+
"Agent instructions can only be delivered via Qwen account personalization, but QWEN_PERSONALIZATION_FROM_REQUEST is disabled. Re-enable it or remove the system instructions from the request.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const estimatedTokens = estimateTokenCount(
|
|
154
|
+
completeInstructions,
|
|
155
|
+
activePrompt,
|
|
156
|
+
);
|
|
157
|
+
// Instructions are delivered exclusively via account-level personalization;
|
|
158
|
+
// the prompt carries only the conversation. Title generation does not sync
|
|
159
|
+
// personalization, so it keeps its (small) instructions inline.
|
|
160
|
+
const finalPrompt =
|
|
161
|
+
isTitleGenerationRequest && completeInstructions
|
|
162
|
+
? `${completeInstructions}\n${activePrompt}`
|
|
163
|
+
: activePrompt;
|
|
164
|
+
|
|
165
|
+
// Truncation is deferred to tryCreateStreamWithRetry, which runs after the
|
|
166
|
+
// account is selected and the real model context window has been synced from
|
|
167
|
+
// Qwen's /api/models catalog. The early context build only performs the byte
|
|
168
|
+
// limit check; the authoritative token check happens downstream.
|
|
169
|
+
assertPromptWithinLimits(finalPrompt, modelId, { checkModelContext: false });
|
|
170
|
+
|
|
171
|
+
const isThinkingModel = enableThinking;
|
|
172
|
+
const shouldResetUpstreamThread = false;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
finalPrompt,
|
|
176
|
+
sessionId,
|
|
177
|
+
existingThread: !!existingThread,
|
|
178
|
+
shouldResetUpstreamThread,
|
|
179
|
+
isNewSession,
|
|
180
|
+
useThreadNative,
|
|
181
|
+
// Thread state is only persisted in thread mode (temp chats are ephemeral).
|
|
182
|
+
updateLogicalThread: isTempMode ? false : useThreadNative,
|
|
183
|
+
chatMode,
|
|
184
|
+
isThinkingModel,
|
|
185
|
+
estimatedTokens,
|
|
186
|
+
modelContextWindow,
|
|
187
|
+
isTitleGenerationRequest,
|
|
188
|
+
requestPersonalizationInstruction: useRequestPersonalization
|
|
189
|
+
? personalizationInstruction
|
|
190
|
+
: null,
|
|
191
|
+
hasExplicitConversationKey,
|
|
192
|
+
allowThreadReuse,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isContinuationMessage(message: Message): boolean {
|
|
197
|
+
return (
|
|
198
|
+
message.role === "assistant" ||
|
|
199
|
+
message.role === "tool" ||
|
|
200
|
+
message.role === "function" ||
|
|
201
|
+
(Array.isArray(message.tool_calls) && message.tool_calls.length > 0)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function extractMessageText(message: Message | undefined): string {
|
|
206
|
+
if (!message) return "";
|
|
207
|
+
const content: unknown = message.content;
|
|
208
|
+
if (typeof content === "string") return content;
|
|
209
|
+
if (Array.isArray(content)) {
|
|
210
|
+
return content
|
|
211
|
+
.map((part: any) => (part?.type === "text" ? part.text || "" : ""))
|
|
212
|
+
.join("\n");
|
|
213
|
+
}
|
|
214
|
+
if (content && typeof content === "object") return JSON.stringify(content);
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function detectTrailingToolResult(messages: Message[]): boolean {
|
|
219
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
220
|
+
const role = messages[i].role;
|
|
221
|
+
if (role === "system") continue;
|
|
222
|
+
return role === "tool" || role === "function";
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function detectTitleGenerationRequest(messages: Message[]): boolean {
|
|
228
|
+
if (messages.length < 2) return false;
|
|
229
|
+
const last = messages[messages.length - 1];
|
|
230
|
+
if (last?.role !== "user") return false;
|
|
231
|
+
|
|
232
|
+
const text = extractMessageText(last).toLowerCase();
|
|
233
|
+
if (!text) return false;
|
|
234
|
+
|
|
235
|
+
// The first pattern is strictly subsumed by the second (which does not
|
|
236
|
+
// require the leading verb), so it is redundant and was removed.
|
|
237
|
+
return (
|
|
238
|
+
/\btitle\b[\s\S]{0,80}\bconversation\b/.test(text) ||
|
|
239
|
+
/\bconversation\b[\s\S]{0,80}\btitle\b/.test(text)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse a non-SSE upstream body that may contain a Qwen error payload.
|
|
3
|
-
* Returns null when the body is not a recognized error document.
|
|
4
|
-
*/
|
|
5
|
-
export interface ParsedQwenErrorPayload {
|
|
6
|
-
code: string;
|
|
7
|
-
details: string;
|
|
8
|
-
message: string;
|
|
9
|
-
status: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function isWafChallenge(value: string): boolean {
|
|
13
|
-
const normalized = value.toLowerCase();
|
|
14
|
-
return (
|
|
15
|
-
normalized.includes("aliyun_waf") ||
|
|
16
|
-
normalized.includes("_____tmd_____") ||
|
|
17
|
-
normalized.includes("fail_sys_user_validate") ||
|
|
18
|
-
normalized.includes("rgv587_error") ||
|
|
19
|
-
normalized.includes("denyfromx5") ||
|
|
20
|
-
normalized.includes("captcha") ||
|
|
21
|
-
normalized.includes("security verification")
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Parse an upstream response that arrived before any SSE event. The returned
|
|
27
|
-
* details are sanitized so an HTML WAF page is never sent back to API clients.
|
|
28
|
-
*/
|
|
29
|
-
export function parseQwenErrorPayload(
|
|
30
|
-
raw: string,
|
|
31
|
-
): ParsedQwenErrorPayload | null {
|
|
32
|
-
const text = raw.trim();
|
|
33
|
-
if (!text || text.startsWith("data:")) return null;
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const payload = JSON.parse(text);
|
|
37
|
-
if (payload && payload.success === false) {
|
|
38
|
-
const code = payload.data?.code || payload.code || "UpstreamError";
|
|
39
|
-
const details =
|
|
40
|
-
payload.data?.details || payload.message || "Qwen returned an error";
|
|
41
|
-
const wait =
|
|
42
|
-
payload.data?.num !== undefined
|
|
43
|
-
? ` Wait about ${payload.data.num} hour(s) before trying again.`
|
|
44
|
-
: "";
|
|
45
|
-
const status =
|
|
46
|
-
code === "RateLimited" ? 429 : code === "Not_Found" ? 404 : 502;
|
|
47
|
-
return {
|
|
48
|
-
code,
|
|
49
|
-
details,
|
|
50
|
-
message: `Qwen upstream error: ${code}: ${details}.${wait}`,
|
|
51
|
-
status,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
if (payload && payload.error) {
|
|
55
|
-
const error = payload.error;
|
|
56
|
-
const code =
|
|
57
|
-
typeof error === "object" && error?.code
|
|
58
|
-
? error.code
|
|
59
|
-
: payload.code || "UpstreamError";
|
|
60
|
-
const details =
|
|
61
|
-
typeof error === "string"
|
|
62
|
-
? error
|
|
63
|
-
: error.details || error.message || JSON.stringify(error);
|
|
64
|
-
return {
|
|
65
|
-
code,
|
|
66
|
-
details,
|
|
67
|
-
message: `Qwen upstream error: ${code}: ${details}`,
|
|
68
|
-
status: 502,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
} catch {
|
|
72
|
-
const waf = isWafChallenge(text);
|
|
73
|
-
const details = waf
|
|
74
|
-
? "Qwen returned an anti-bot challenge instead of an SSE response."
|
|
75
|
-
: "Qwen returned a non-SSE response before generation started.";
|
|
76
|
-
return {
|
|
77
|
-
code: waf ? "waf_challenge" : "non_sse_response",
|
|
78
|
-
details,
|
|
79
|
-
message: `Qwen upstream error: ${details}`,
|
|
80
|
-
status: 502,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Parse a non-SSE upstream body that may contain a Qwen error payload.
|
|
3
|
+
* Returns null when the body is not a recognized error document.
|
|
4
|
+
*/
|
|
5
|
+
export interface ParsedQwenErrorPayload {
|
|
6
|
+
code: string;
|
|
7
|
+
details: string;
|
|
8
|
+
message: string;
|
|
9
|
+
status: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isWafChallenge(value: string): boolean {
|
|
13
|
+
const normalized = value.toLowerCase();
|
|
14
|
+
return (
|
|
15
|
+
normalized.includes("aliyun_waf") ||
|
|
16
|
+
normalized.includes("_____tmd_____") ||
|
|
17
|
+
normalized.includes("fail_sys_user_validate") ||
|
|
18
|
+
normalized.includes("rgv587_error") ||
|
|
19
|
+
normalized.includes("denyfromx5") ||
|
|
20
|
+
normalized.includes("captcha") ||
|
|
21
|
+
normalized.includes("security verification")
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse an upstream response that arrived before any SSE event. The returned
|
|
27
|
+
* details are sanitized so an HTML WAF page is never sent back to API clients.
|
|
28
|
+
*/
|
|
29
|
+
export function parseQwenErrorPayload(
|
|
30
|
+
raw: string,
|
|
31
|
+
): ParsedQwenErrorPayload | null {
|
|
32
|
+
const text = raw.trim();
|
|
33
|
+
if (!text || text.startsWith("data:")) return null;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const payload = JSON.parse(text);
|
|
37
|
+
if (payload && payload.success === false) {
|
|
38
|
+
const code = payload.data?.code || payload.code || "UpstreamError";
|
|
39
|
+
const details =
|
|
40
|
+
payload.data?.details || payload.message || "Qwen returned an error";
|
|
41
|
+
const wait =
|
|
42
|
+
payload.data?.num !== undefined
|
|
43
|
+
? ` Wait about ${payload.data.num} hour(s) before trying again.`
|
|
44
|
+
: "";
|
|
45
|
+
const status =
|
|
46
|
+
code === "RateLimited" ? 429 : code === "Not_Found" ? 404 : 502;
|
|
47
|
+
return {
|
|
48
|
+
code,
|
|
49
|
+
details,
|
|
50
|
+
message: `Qwen upstream error: ${code}: ${details}.${wait}`,
|
|
51
|
+
status,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (payload && payload.error) {
|
|
55
|
+
const error = payload.error;
|
|
56
|
+
const code =
|
|
57
|
+
typeof error === "object" && error?.code
|
|
58
|
+
? error.code
|
|
59
|
+
: payload.code || "UpstreamError";
|
|
60
|
+
const details =
|
|
61
|
+
typeof error === "string"
|
|
62
|
+
? error
|
|
63
|
+
: error.details || error.message || JSON.stringify(error);
|
|
64
|
+
return {
|
|
65
|
+
code,
|
|
66
|
+
details,
|
|
67
|
+
message: `Qwen upstream error: ${code}: ${details}`,
|
|
68
|
+
status: 502,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
const waf = isWafChallenge(text);
|
|
73
|
+
const details = waf
|
|
74
|
+
? "Qwen returned an anti-bot challenge instead of an SSE response."
|
|
75
|
+
: "Qwen returned a non-SSE response before generation started.";
|
|
76
|
+
return {
|
|
77
|
+
code: waf ? "waf_challenge" : "non_sse_response",
|
|
78
|
+
details,
|
|
79
|
+
message: `Qwen upstream error: ${details}`,
|
|
80
|
+
status: 502,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|