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
package/src/routes/chat/index.ts
CHANGED
|
@@ -1,618 +1,618 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thin orchestrator for chat completions. Delegates to specialized modules:
|
|
3
|
-
* - validation.ts: request parsing
|
|
4
|
-
* - context.ts: prompt building and topic analysis
|
|
5
|
-
* - account.ts: upstream stream acquisition with failover
|
|
6
|
-
* - streaming.ts: response processing (SSE/JSON)
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { Context } from "hono";
|
|
10
|
-
import { parseRequestBody } from "./validation.ts";
|
|
11
|
-
import { buildFinalContext } from "./context.ts";
|
|
12
|
-
import { acquireUpstreamStream, acquireChatLock } from "./account.ts";
|
|
13
|
-
import {
|
|
14
|
-
abortLeaseBySessionLabel,
|
|
15
|
-
hasUnemittedSessionStream,
|
|
16
|
-
} from "../../core/account-concurrency.ts";
|
|
17
|
-
import {
|
|
18
|
-
processNonStreamingResponse,
|
|
19
|
-
processStreamingResponse,
|
|
20
|
-
handleChatCompletionsError,
|
|
21
|
-
type AssistantCompleteEvent,
|
|
22
|
-
} from "./streaming.ts";
|
|
23
|
-
import { config, type ChatMode } from "../../core/config.ts";
|
|
24
|
-
import { logger } from "../../core/logger.ts";
|
|
25
|
-
import { getContextMeterHeaders, type ContextMeterMode } from "../../services/context-meter.ts";
|
|
26
|
-
import {
|
|
27
|
-
getLogicalThreadState,
|
|
28
|
-
invalidateLogicalThreadParent,
|
|
29
|
-
RetryableQwenStreamError,
|
|
30
|
-
} from "../../services/qwen.ts";
|
|
31
|
-
import {
|
|
32
|
-
classifyRetryAction,
|
|
33
|
-
shouldRetryInvalidInputOnSameAccount,
|
|
34
|
-
} from "./retry-policy.ts";
|
|
35
|
-
import { classifyMediaModel } from "../../services/media-generation.ts";
|
|
36
|
-
import { handleMediaChatCompletion } from "./media.ts";
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
function formatTimingHeader(timings: Record<string, number>): string {
|
|
41
|
-
return Object.entries(timings)
|
|
42
|
-
.map(([key, value]) => `${key}=${Math.max(0, Math.round(value))}`)
|
|
43
|
-
.join(";");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Per-request chat-mode override (X-QwenProxy-Chat-Mode) falls back to the
|
|
48
|
-
* QWEN_CHAT_MODE env default. Only the two known modes are accepted; anything
|
|
49
|
-
* else silently uses the configured default.
|
|
50
|
-
*/
|
|
51
|
-
function resolveChatMode(headerValue: string | undefined): ChatMode {
|
|
52
|
-
if (headerValue === "thread" || headerValue === "temp") return headerValue;
|
|
53
|
-
return config.qwen.chatMode;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function chatCompletions(c: Context) {
|
|
57
|
-
let releaseChatLock: (() => void) | null = null;
|
|
58
|
-
const startedAt = Date.now();
|
|
59
|
-
const timings: Record<string, number> = {};
|
|
60
|
-
const mark = (name: string, since: number) => {
|
|
61
|
-
timings[name] = Date.now() - since;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
let stepStartedAt = Date.now();
|
|
66
|
-
const parsed = await parseRequestBody(c);
|
|
67
|
-
mark("parse", stepStartedAt);
|
|
68
|
-
const {
|
|
69
|
-
body,
|
|
70
|
-
isStream,
|
|
71
|
-
systemPrompt,
|
|
72
|
-
toolInstructions,
|
|
73
|
-
prompt,
|
|
74
|
-
currentPrompt,
|
|
75
|
-
modelId,
|
|
76
|
-
enableThinking,
|
|
77
|
-
reasoningMode,
|
|
78
|
-
allFiles,
|
|
79
|
-
currentFiles,
|
|
80
|
-
shouldParseToolCalls,
|
|
81
|
-
conversationKey,
|
|
82
|
-
} = parsed;
|
|
83
|
-
|
|
84
|
-
const messages = body.messages || [];
|
|
85
|
-
const declaredTools = Array.isArray((body as any).tools)
|
|
86
|
-
? (body as any).tools
|
|
87
|
-
: [];
|
|
88
|
-
|
|
89
|
-
// Correlate arrival and dispatch: logged again on the 📤 line once the
|
|
90
|
-
// upstream stream (and its queue wait) is resolved.
|
|
91
|
-
const reqId = crypto.randomUUID().substring(0, 8);
|
|
92
|
-
const reqStartedAt = Date.now();
|
|
93
|
-
const routeLabel = c.req.header("x-qwenproxy-route") || "Chat";
|
|
94
|
-
console.log(
|
|
95
|
-
`📥 [${routeLabel}] Incoming | req=${reqId} | ${body.model} | ${messages.length} msg(s) | stream=${isStream}${declaredTools.length ? ` | ${declaredTools.length} tool(s)` : ""}${allFiles.length ? ` | ${allFiles.length} file(s)` : ""}`,
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
// Intercept image/video generation models: they bypass the text chat flow
|
|
99
|
-
// and are handled by the native media pipeline (qwen-image-*, wan2.*).
|
|
100
|
-
const rawModel = typeof body.model === "string" ? body.model.trim() : "";
|
|
101
|
-
const mediaKind = rawModel ? classifyMediaModel(rawModel) : null;
|
|
102
|
-
if (mediaKind) {
|
|
103
|
-
return handleMediaChatCompletion({
|
|
104
|
-
c,
|
|
105
|
-
body,
|
|
106
|
-
model: rawModel,
|
|
107
|
-
kind: mediaKind,
|
|
108
|
-
isStream,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
stepStartedAt = Date.now();
|
|
113
|
-
const chatMode = resolveChatMode(c.req.header("x-qwenproxy-chat-mode"));
|
|
114
|
-
const ctx = await buildFinalContext({
|
|
115
|
-
messages,
|
|
116
|
-
systemPrompt,
|
|
117
|
-
toolInstructions,
|
|
118
|
-
prompt,
|
|
119
|
-
currentPrompt,
|
|
120
|
-
modelId,
|
|
121
|
-
enableThinking,
|
|
122
|
-
conversationKey,
|
|
123
|
-
hasExplicitConversationKey: parsed.hasExplicitConversationKey,
|
|
124
|
-
chatMode,
|
|
125
|
-
});
|
|
126
|
-
mark("context", stepStartedAt);
|
|
127
|
-
|
|
128
|
-
// Chat lock is acquired AFTER stream creation (below) to avoid holding it
|
|
129
|
-
// during account selection, retries, and anti-bot recovery which can take
|
|
130
|
-
// 30s+. Holding it here caused 190s+ lock contention cascading to all
|
|
131
|
-
// subsequent requests on the same chat.
|
|
132
|
-
mark("lock", stepStartedAt);
|
|
133
|
-
|
|
134
|
-
let finalPrompt = ctx.finalPrompt;
|
|
135
|
-
mark("thread", stepStartedAt);
|
|
136
|
-
|
|
137
|
-
const files = ctx.useThreadNative ? currentFiles : allFiles;
|
|
138
|
-
|
|
139
|
-
const msgCount =
|
|
140
|
-
ctx.useThreadNative && !ctx.isNewSession
|
|
141
|
-
? parsed.currentMessageCount
|
|
142
|
-
: parsed.messageCount;
|
|
143
|
-
|
|
144
|
-
const personalizationChars =
|
|
145
|
-
ctx.requestPersonalizationInstruction?.length ?? 0;
|
|
146
|
-
logger.debug("[chat] request routing details", {
|
|
147
|
-
model: body.model,
|
|
148
|
-
messages: msgCount,
|
|
149
|
-
promptChars: finalPrompt.length,
|
|
150
|
-
tools: declaredTools.length,
|
|
151
|
-
files: files.length,
|
|
152
|
-
personalizationChars,
|
|
153
|
-
sessionId: ctx.sessionId,
|
|
154
|
-
useThreadNative: ctx.useThreadNative,
|
|
155
|
-
isNewSession: ctx.isNewSession,
|
|
156
|
-
hasExplicitConversationKey: ctx.hasExplicitConversationKey,
|
|
157
|
-
allowThreadReuse: ctx.allowThreadReuse,
|
|
158
|
-
sessionIdentitySource: parsed.hasExplicitConversationKey
|
|
159
|
-
? typeof body.session_id === "string" &&
|
|
160
|
-
body.session_id.trim().length > 0
|
|
161
|
-
? "session_id"
|
|
162
|
-
: "conversation_id"
|
|
163
|
-
: ctx.isNewSession
|
|
164
|
-
? "none-new-chat"
|
|
165
|
-
: "implicit-continuation",
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
stepStartedAt = Date.now();
|
|
169
|
-
// Full replay carries the conversation only: agent instructions ride the
|
|
170
|
-
// account-level personalization, which is confirmed on the destination
|
|
171
|
-
// account BEFORE the replayed completion is sent (an unconfirmed sync now
|
|
172
|
-
// fails the attempt instead of degrading to inline). Title generation does
|
|
173
|
-
// not sync personalization and keeps the legacy inline replay.
|
|
174
|
-
const fullPromptForRequest =
|
|
175
|
-
ctx.requestPersonalizationInstruction !== null
|
|
176
|
-
? parsed.prompt
|
|
177
|
-
: [parsed.systemPrompt, parsed.toolInstructions, parsed.prompt]
|
|
178
|
-
.filter((part) => part.trim().length > 0)
|
|
179
|
-
.join("\n\n");
|
|
180
|
-
const initialContextMode: ContextMeterMode = ctx.existingThread
|
|
181
|
-
? "delta"
|
|
182
|
-
: "full";
|
|
183
|
-
|
|
184
|
-
// Same-session latest-wins BEFORE the per-chat lock and BEFORE the stream
|
|
185
|
-
// acquisition: the client can fire the next turn while the previous stream
|
|
186
|
-
// is still open (streaming tool calls). Killing the stale generation first
|
|
187
|
-
// frees the account slot + chat lock immediately instead of queueing.
|
|
188
|
-
// onlyIfEmitted: a stream that has NOT emitted a chunk yet is protected —
|
|
189
|
-
// a parallel request (e.g. the client's title generation racing the main
|
|
190
|
-
// request) must not waste the main generation. In that case this request
|
|
191
|
-
// runs on its OWN chat (parallelEscape) instead of waiting on the main
|
|
192
|
-
// chat's lock for minutes.
|
|
193
|
-
let parallelEscape = false;
|
|
194
|
-
if (ctx.allowThreadReuse && ctx.sessionId) {
|
|
195
|
-
const superseded = abortLeaseBySessionLabel(ctx.sessionId, {
|
|
196
|
-
onlyIfEmitted: true,
|
|
197
|
-
});
|
|
198
|
-
const existingThread = getLogicalThreadState(ctx.sessionId);
|
|
199
|
-
const chatId = existingThread?.chatSessionId;
|
|
200
|
-
// Escape ONLY when an unemitted stream is actually active (a lease
|
|
201
|
-
// exists but was protected). No active lease (normal next turn) takes
|
|
202
|
-
// the regular path.
|
|
203
|
-
parallelEscape =
|
|
204
|
-
!!chatId && !superseded && hasUnemittedSessionStream(ctx.sessionId);
|
|
205
|
-
if (parallelEscape && logger.isLevelEnabled("info")) {
|
|
206
|
-
console.log(
|
|
207
|
-
`🔀 [Chat] Parallel escape | req=${reqId} | session=${ctx.sessionId} | chat=${chatId?.substring(0, 12)} | own chat`,
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
if (chatId && !parallelEscape) {
|
|
211
|
-
releaseChatLock = await acquireChatLock(chatId);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
let streamResult = await acquireUpstreamStream({
|
|
216
|
-
finalPrompt,
|
|
217
|
-
fullPrompt: fullPromptForRequest,
|
|
218
|
-
isThinkingModel: ctx.isThinkingModel,
|
|
219
|
-
model: modelId,
|
|
220
|
-
reasoningMode,
|
|
221
|
-
contextModelId: modelId,
|
|
222
|
-
shouldResetUpstreamThread: ctx.shouldResetUpstreamThread,
|
|
223
|
-
allFiles: files,
|
|
224
|
-
isNewSession: ctx.isNewSession,
|
|
225
|
-
sessionId: ctx.sessionId,
|
|
226
|
-
useThreadNative: ctx.useThreadNative,
|
|
227
|
-
updateLogicalThread: parallelEscape ? false : ctx.updateLogicalThread,
|
|
228
|
-
allowThreadReuse: ctx.allowThreadReuse,
|
|
229
|
-
forceNewChat: parallelEscape,
|
|
230
|
-
preferredAccountId: undefined,
|
|
231
|
-
messageCount: msgCount,
|
|
232
|
-
fullMessageCount: parsed.messageCount,
|
|
233
|
-
toolsCount: declaredTools.length || undefined,
|
|
234
|
-
requestPersonalizationInstruction: ctx.requestPersonalizationInstruction,
|
|
235
|
-
contextMode: initialContextMode,
|
|
236
|
-
requestSignal: c.req.raw.signal,
|
|
237
|
-
messages,
|
|
238
|
-
parallelEscape,
|
|
239
|
-
chatMode,
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
mark("upstream", stepStartedAt);
|
|
245
|
-
timings.preResponse = Date.now() - startedAt;
|
|
246
|
-
c.header("X-QwenProxy-Timing", formatTimingHeader(timings));
|
|
247
|
-
|
|
248
|
-
if ("error" in streamResult) {
|
|
249
|
-
if (streamResult.allOnCooldown) {
|
|
250
|
-
const err: any = new Error(
|
|
251
|
-
`All configured accounts are on cooldown. Retry in about ${Math.max(
|
|
252
|
-
1,
|
|
253
|
-
Math.ceil((streamResult.retryAfterMs ?? 0) / 1000),
|
|
254
|
-
)}s.`,
|
|
255
|
-
);
|
|
256
|
-
err.upstreamStatus = 429;
|
|
257
|
-
throw err;
|
|
258
|
-
}
|
|
259
|
-
throw streamResult.error || new Error("All accounts failed");
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
for (const [name, value] of Object.entries(
|
|
263
|
-
getContextMeterHeaders(streamResult.tokenEstimationContext.contextMeter),
|
|
264
|
-
)) {
|
|
265
|
-
c.header(name, value);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// A full-context replay (account switch / missing thread parent) hides its
|
|
269
|
-
// real cost behind the thread-native delta numbers: surface it explicitly
|
|
270
|
-
// so the 📤 line shows what was actually sent upstream.
|
|
271
|
-
const replayed = streamResult.replayedFullContext === true;
|
|
272
|
-
console.log(
|
|
273
|
-
`📤 [${routeLabel}] Request | req=${reqId} | ${streamResult.activeAccountLabel} | ${body.model} | ${replayed ? parsed.messageCount : msgCount} msg(s) | ${replayed ? fullPromptForRequest.length : finalPrompt.length} chars${replayed ? " | full-replay" : ""} | chat=${streamResult.uiSessionId.substring(0, 12)}${declaredTools.length ? ` | ${declaredTools.length} tool(s)` : ""}${files.length ? ` | ${files.length} file(s)` : ""} | +${Date.now() - reqStartedAt}ms`,
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
const onAssistantComplete: ((event: AssistantCompleteEvent) => Promise<void> | void) | undefined = undefined;
|
|
277
|
-
|
|
278
|
-
const params = {
|
|
279
|
-
c,
|
|
280
|
-
reqId,
|
|
281
|
-
completionId: streamResult.completionId,
|
|
282
|
-
stream: streamResult.stream,
|
|
283
|
-
uiSessionId: streamResult.uiSessionId,
|
|
284
|
-
activeAccountId: streamResult.activeAccountId,
|
|
285
|
-
activeAccountLabel: streamResult.activeAccountLabel,
|
|
286
|
-
logicalSessionId: streamResult.logicalSessionId,
|
|
287
|
-
body,
|
|
288
|
-
finalPrompt,
|
|
289
|
-
userPrompt: currentPrompt || prompt,
|
|
290
|
-
shouldParseToolCalls,
|
|
291
|
-
declaredTools,
|
|
292
|
-
tokenEstimationContext: streamResult.tokenEstimationContext,
|
|
293
|
-
midStreamRetry: {
|
|
294
|
-
fullPrompt: fullPromptForRequest,
|
|
295
|
-
isThinkingModel: ctx.isThinkingModel,
|
|
296
|
-
contextModelId: modelId,
|
|
297
|
-
reasoningMode,
|
|
298
|
-
activeAccountId: streamResult.activeAccountId,
|
|
299
|
-
allFiles: files,
|
|
300
|
-
isNewSession: ctx.isNewSession,
|
|
301
|
-
sessionId: ctx.sessionId,
|
|
302
|
-
useThreadNative: ctx.useThreadNative,
|
|
303
|
-
// A parallel request (own chat) must not rebind the session thread on
|
|
304
|
-
// mid-stream recovery — the main conversation owns it.
|
|
305
|
-
updateLogicalThread: parallelEscape
|
|
306
|
-
? false
|
|
307
|
-
: ctx.updateLogicalThread,
|
|
308
|
-
parallelEscape,
|
|
309
|
-
chatMode,
|
|
310
|
-
allowThreadReuse: ctx.allowThreadReuse,
|
|
311
|
-
messageCount: msgCount,
|
|
312
|
-
fullMessageCount: parsed.messageCount,
|
|
313
|
-
toolsCount: declaredTools.length || undefined,
|
|
314
|
-
requestPersonalizationInstruction:
|
|
315
|
-
ctx.requestPersonalizationInstruction,
|
|
316
|
-
contextMode: initialContextMode as ContextMeterMode,
|
|
317
|
-
releaseAccountLease: streamResult.releaseAccountLease,
|
|
318
|
-
messages,
|
|
319
|
-
},
|
|
320
|
-
onAssistantComplete,
|
|
321
|
-
onStreamComplete: () => {
|
|
322
|
-
if (releaseChatLock) {
|
|
323
|
-
releaseChatLock();
|
|
324
|
-
releaseChatLock = null;
|
|
325
|
-
}
|
|
326
|
-
streamResult.releaseAccountLease();
|
|
327
|
-
},
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
// Retry loop for mid-stream/create-stream failures (generic policy)
|
|
331
|
-
let streamProcessingRetries = Math.max(0, config.retry.maxAttempts - 1);
|
|
332
|
-
let invalidInputSameAccountRetries = 0;
|
|
333
|
-
let currentStreamResult = streamResult;
|
|
334
|
-
let currentParams = params;
|
|
335
|
-
|
|
336
|
-
while (true) {
|
|
337
|
-
try {
|
|
338
|
-
return isStream
|
|
339
|
-
? await processStreamingResponse(currentParams)
|
|
340
|
-
: await processNonStreamingResponse(currentParams);
|
|
341
|
-
} catch (streamErr: any) {
|
|
342
|
-
const policy = classifyRetryAction(streamErr, {
|
|
343
|
-
requestAborted: c.req.raw.signal.aborted,
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// Full decision context for the outer retry loop (same rationale as
|
|
347
|
-
// the create-path policy log): the error line shows WHAT failed, this
|
|
348
|
-
// shows WHY the retry action was chosen.
|
|
349
|
-
if (logger.isLevelEnabled("info")) {
|
|
350
|
-
console.log(
|
|
351
|
-
`🧭 [Chat] Stream retry policy | req=${reqId} | reason=${policy.reason} | retryable=${policy.retryable} | switch=${policy.switchAccount} | newChat=${policy.forceNewChat} | retryAfter=${policy.retryAfterMs}ms`,
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (policy.reason === "corrupted_chat_history") {
|
|
356
|
-
invalidateLogicalThreadParent(ctx.sessionId);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
if (policy.reason === "chat_in_progress") {
|
|
360
|
-
// The same-chat settle budget AND the single bounded escalation
|
|
361
|
-
// (fresh chat + full replay) were already spent at the create path
|
|
362
|
-
// before this error surfaced. A request-level retry would restart
|
|
363
|
-
// that whole budget and replay the full context again. Surface the
|
|
364
|
-
// error; the inner loop already cleared the origin binding, so the
|
|
365
|
-
// client's own retry starts a fresh chat.
|
|
366
|
-
throw streamErr;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Prefer explicit RetryableQwenStreamError OR generic retryable policy
|
|
370
|
-
const canRetry =
|
|
371
|
-
streamProcessingRetries > 0 &&
|
|
372
|
-
policy.retryable &&
|
|
373
|
-
(streamErr instanceof RetryableQwenStreamError ||
|
|
374
|
-
config.retry.onUnknownUpstream !== false);
|
|
375
|
-
|
|
376
|
-
if (!canRetry) {
|
|
377
|
-
// Terminal (or retry budget exhausted): say WHY instead of just
|
|
378
|
-
// letting the error bubble to handleChatCompletionsError — the
|
|
379
|
-
// operator must distinguish "upstream refused" from "our retry
|
|
380
|
-
// budget ran out".
|
|
381
|
-
if (logger.isLevelEnabled("info")) {
|
|
382
|
-
console.log(
|
|
383
|
-
`⛔ [Chat] Stream retry exhausted | req=${reqId} | reason=${policy.reason} | retriesLeft=${streamProcessingRetries} | retryable=${policy.retryable} | error=${streamErr?.message?.substring(0, 150)}`,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
throw streamErr;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
streamProcessingRetries--;
|
|
390
|
-
console.warn(
|
|
391
|
-
`[Chat] Stream processing error, retrying with new stream | reason=${policy.reason} | ${streamErr.message?.substring(0, 150)} | retries left: ${streamProcessingRetries}`,
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
// Recover a generic invalid_input on the same account once by
|
|
395
|
-
// creating a clean upstream chat. A second failure may rotate.
|
|
396
|
-
const retryInvalidInputOnSameAccount =
|
|
397
|
-
shouldRetryInvalidInputOnSameAccount(
|
|
398
|
-
policy.reason,
|
|
399
|
-
invalidInputSameAccountRetries > 0,
|
|
400
|
-
);
|
|
401
|
-
if (retryInvalidInputOnSameAccount) {
|
|
402
|
-
invalidInputSameAccountRetries++;
|
|
403
|
-
}
|
|
404
|
-
const switchAccount =
|
|
405
|
-
policy.switchAccount && !retryInvalidInputOnSameAccount;
|
|
406
|
-
const forceRetryNewChat = policy.forceNewChat;
|
|
407
|
-
const retryWithFullPrompt = policy.retryWithFullPrompt;
|
|
408
|
-
const retryFiles = policy.dropFiles ? [] : files;
|
|
409
|
-
|
|
410
|
-
// Do not cooldown an account when the policy is retrying it in
|
|
411
|
-
// place (temporary load shedding). A cooldown here would make the
|
|
412
|
-
// subsequent preferred-account retry skip that same account.
|
|
413
|
-
if (
|
|
414
|
-
policy.switchAccount &&
|
|
415
|
-
(policy.accountCooldownMs || policy.accountCooldownReason)
|
|
416
|
-
) {
|
|
417
|
-
const { markAccountRateLimited } =
|
|
418
|
-
await import("../../core/account-manager.ts");
|
|
419
|
-
markAccountRateLimited(
|
|
420
|
-
currentStreamResult.activeAccountId,
|
|
421
|
-
policy.accountCooldownMs,
|
|
422
|
-
policy.accountCooldownReason || "StreamRetry",
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Release current chat lock and account lease before retrying
|
|
427
|
-
if (releaseChatLock) {
|
|
428
|
-
releaseChatLock();
|
|
429
|
-
releaseChatLock = null;
|
|
430
|
-
}
|
|
431
|
-
currentStreamResult.releaseAccountLease();
|
|
432
|
-
|
|
433
|
-
// Account switch always rebuilds full history; same-account retry
|
|
434
|
-
// only does so when the policy asks for forceNewChat/full prompt.
|
|
435
|
-
const needsFullPromptOnRetry =
|
|
436
|
-
retryWithFullPrompt || switchAccount || forceRetryNewChat;
|
|
437
|
-
const retryFinalPrompt = needsFullPromptOnRetry
|
|
438
|
-
? fullPromptForRequest
|
|
439
|
-
: finalPrompt;
|
|
440
|
-
const retryMessageCount = needsFullPromptOnRetry
|
|
441
|
-
? parsed.messageCount
|
|
442
|
-
: msgCount;
|
|
443
|
-
|
|
444
|
-
if (forceRetryNewChat || switchAccount) {
|
|
445
|
-
console.warn(
|
|
446
|
-
`[Chat] Retry will force a new upstream chat and resend full context | ${streamErr.message?.substring(0, 150)}`,
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
if (switchAccount) {
|
|
450
|
-
console.warn(
|
|
451
|
-
`[Chat] Retry will prefer another account when available | ${streamErr.message?.substring(0, 150)}`,
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (policy.retryAfterMs > 0) {
|
|
456
|
-
await new Promise((resolve) =>
|
|
457
|
-
setTimeout(resolve, Math.min(policy.retryAfterMs, 3000)),
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Same-session latest-wins before re-acquiring: protects an
|
|
462
|
-
// unemitted generation (parallel title request) which then runs on
|
|
463
|
-
// its own chat via retryParallelEscape.
|
|
464
|
-
let retryParallelEscape = false;
|
|
465
|
-
if (ctx.allowThreadReuse && ctx.sessionId) {
|
|
466
|
-
const superseded = abortLeaseBySessionLabel(ctx.sessionId, {
|
|
467
|
-
onlyIfEmitted: true,
|
|
468
|
-
});
|
|
469
|
-
const existingThread = getLogicalThreadState(ctx.sessionId);
|
|
470
|
-
const chatId = existingThread?.chatSessionId;
|
|
471
|
-
retryParallelEscape =
|
|
472
|
-
parallelEscape ||
|
|
473
|
-
(!!chatId &&
|
|
474
|
-
!superseded &&
|
|
475
|
-
hasUnemittedSessionStream(ctx.sessionId));
|
|
476
|
-
if (retryParallelEscape && logger.isLevelEnabled("info")) {
|
|
477
|
-
console.log(
|
|
478
|
-
`🔀 [Chat] Parallel escape (retry) | req=${reqId} | session=${ctx.sessionId} | chat=${chatId?.substring(0, 12)} | own chat`,
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
if (chatId && !retryParallelEscape) {
|
|
482
|
-
releaseChatLock = await acquireChatLock(chatId);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Re-acquire stream with different account or a fresh upstream chat
|
|
487
|
-
const newStreamResult = await acquireUpstreamStream({
|
|
488
|
-
finalPrompt: retryFinalPrompt,
|
|
489
|
-
fullPrompt: fullPromptForRequest,
|
|
490
|
-
isThinkingModel: ctx.isThinkingModel,
|
|
491
|
-
model: modelId,
|
|
492
|
-
contextModelId: modelId,
|
|
493
|
-
shouldResetUpstreamThread: ctx.shouldResetUpstreamThread,
|
|
494
|
-
allFiles: retryFiles,
|
|
495
|
-
isNewSession: ctx.isNewSession,
|
|
496
|
-
sessionId: ctx.sessionId,
|
|
497
|
-
useThreadNative: ctx.useThreadNative,
|
|
498
|
-
updateLogicalThread: retryParallelEscape
|
|
499
|
-
? false
|
|
500
|
-
: ctx.updateLogicalThread,
|
|
501
|
-
allowThreadReuse: ctx.allowThreadReuse,
|
|
502
|
-
forceNewChat:
|
|
503
|
-
forceRetryNewChat || switchAccount || retryParallelEscape,
|
|
504
|
-
preferredAccountId: switchAccount
|
|
505
|
-
? null
|
|
506
|
-
: currentStreamResult.activeAccountId,
|
|
507
|
-
excludeAccountIds: switchAccount
|
|
508
|
-
? [currentStreamResult.activeAccountId]
|
|
509
|
-
: undefined,
|
|
510
|
-
messageCount: retryMessageCount,
|
|
511
|
-
fullMessageCount: parsed.messageCount,
|
|
512
|
-
toolsCount: declaredTools.length || undefined,
|
|
513
|
-
requestPersonalizationInstruction:
|
|
514
|
-
ctx.requestPersonalizationInstruction,
|
|
515
|
-
contextMode: needsFullPromptOnRetry ? "replay" : initialContextMode,
|
|
516
|
-
requestSignal: c.req.raw.signal,
|
|
517
|
-
messages,
|
|
518
|
-
parallelEscape: retryParallelEscape,
|
|
519
|
-
chatMode,
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
if ("error" in newStreamResult) {
|
|
523
|
-
// Prefer a local preflight error over the upstream error that
|
|
524
|
-
// triggered the replay (for example, an oversized full context).
|
|
525
|
-
throw newStreamResult.error ?? streamErr;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
for (const [name, value] of Object.entries(
|
|
529
|
-
getContextMeterHeaders(
|
|
530
|
-
newStreamResult.tokenEstimationContext?.contextMeter,
|
|
531
|
-
),
|
|
532
|
-
)) {
|
|
533
|
-
c.header(name, value);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
console.log(
|
|
537
|
-
`🔄 [Chat] Request routed | ${newStreamResult.activeAccountLabel} | ${body.model} | ${retryMessageCount} msg(s) | ${retryFinalPrompt.length} chars | chat=${newStreamResult.uiSessionId.substring(0, 12)}${declaredTools.length ? ` | ${declaredTools.length} tool(s)` : ""}${files.length ? ` | ${files.length} file(s)` : ""} | retry | +${Date.now() - reqStartedAt}ms`,
|
|
538
|
-
);
|
|
539
|
-
|
|
540
|
-
currentStreamResult = newStreamResult;
|
|
541
|
-
currentParams = {
|
|
542
|
-
c,
|
|
543
|
-
reqId,
|
|
544
|
-
completionId: newStreamResult.completionId,
|
|
545
|
-
stream: newStreamResult.stream,
|
|
546
|
-
uiSessionId: newStreamResult.uiSessionId,
|
|
547
|
-
activeAccountId: newStreamResult.activeAccountId,
|
|
548
|
-
activeAccountLabel: newStreamResult.activeAccountLabel,
|
|
549
|
-
logicalSessionId: newStreamResult.logicalSessionId,
|
|
550
|
-
body,
|
|
551
|
-
finalPrompt: retryFinalPrompt,
|
|
552
|
-
userPrompt: currentPrompt || prompt,
|
|
553
|
-
shouldParseToolCalls,
|
|
554
|
-
declaredTools,
|
|
555
|
-
tokenEstimationContext: newStreamResult.tokenEstimationContext,
|
|
556
|
-
midStreamRetry: {
|
|
557
|
-
fullPrompt: fullPromptForRequest,
|
|
558
|
-
isThinkingModel: ctx.isThinkingModel,
|
|
559
|
-
contextModelId: modelId,
|
|
560
|
-
reasoningMode,
|
|
561
|
-
activeAccountId: newStreamResult.activeAccountId,
|
|
562
|
-
allFiles: retryFiles,
|
|
563
|
-
isNewSession: ctx.isNewSession,
|
|
564
|
-
sessionId: ctx.sessionId,
|
|
565
|
-
useThreadNative: ctx.useThreadNative,
|
|
566
|
-
updateLogicalThread: retryParallelEscape
|
|
567
|
-
? false
|
|
568
|
-
: ctx.updateLogicalThread,
|
|
569
|
-
parallelEscape: retryParallelEscape,
|
|
570
|
-
chatMode,
|
|
571
|
-
allowThreadReuse: ctx.allowThreadReuse,
|
|
572
|
-
messageCount: retryMessageCount,
|
|
573
|
-
fullMessageCount: parsed.messageCount,
|
|
574
|
-
toolsCount: declaredTools.length || undefined,
|
|
575
|
-
requestPersonalizationInstruction:
|
|
576
|
-
ctx.requestPersonalizationInstruction,
|
|
577
|
-
contextMode: needsFullPromptOnRetry
|
|
578
|
-
? "replay"
|
|
579
|
-
: initialContextMode,
|
|
580
|
-
releaseAccountLease: newStreamResult.releaseAccountLease,
|
|
581
|
-
messages,
|
|
582
|
-
},
|
|
583
|
-
onAssistantComplete,
|
|
584
|
-
onStreamComplete: () => {
|
|
585
|
-
if (releaseChatLock) {
|
|
586
|
-
releaseChatLock();
|
|
587
|
-
releaseChatLock = null;
|
|
588
|
-
}
|
|
589
|
-
newStreamResult.releaseAccountLease();
|
|
590
|
-
},
|
|
591
|
-
};
|
|
592
|
-
continue;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
} catch (err) {
|
|
596
|
-
timings.preResponse = Date.now() - startedAt;
|
|
597
|
-
c.header("X-QwenProxy-Timing", formatTimingHeader(timings));
|
|
598
|
-
if (releaseChatLock) {
|
|
599
|
-
releaseChatLock();
|
|
600
|
-
releaseChatLock = null;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// The client is already gone; do not turn expected cancellation into a
|
|
604
|
-
// misleading 500/internal_server_error log or retry response.
|
|
605
|
-
if (c.req.raw.signal.aborted) {
|
|
606
|
-
logger.debug("[chat] request aborted before response", {
|
|
607
|
-
error: err instanceof Error ? err.message : String(err),
|
|
608
|
-
});
|
|
609
|
-
return new Response(null, { status: 499 });
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return handleChatCompletionsError(c, err);
|
|
613
|
-
} finally {
|
|
614
|
-
// Lock released via onStreamComplete when stream finishes
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
export { chatCompletionsStop } from "./stop.ts";
|
|
1
|
+
/**
|
|
2
|
+
* Thin orchestrator for chat completions. Delegates to specialized modules:
|
|
3
|
+
* - validation.ts: request parsing
|
|
4
|
+
* - context.ts: prompt building and topic analysis
|
|
5
|
+
* - account.ts: upstream stream acquisition with failover
|
|
6
|
+
* - streaming.ts: response processing (SSE/JSON)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Context } from "hono";
|
|
10
|
+
import { parseRequestBody } from "./validation.ts";
|
|
11
|
+
import { buildFinalContext } from "./context.ts";
|
|
12
|
+
import { acquireUpstreamStream, acquireChatLock } from "./account.ts";
|
|
13
|
+
import {
|
|
14
|
+
abortLeaseBySessionLabel,
|
|
15
|
+
hasUnemittedSessionStream,
|
|
16
|
+
} from "../../core/account-concurrency.ts";
|
|
17
|
+
import {
|
|
18
|
+
processNonStreamingResponse,
|
|
19
|
+
processStreamingResponse,
|
|
20
|
+
handleChatCompletionsError,
|
|
21
|
+
type AssistantCompleteEvent,
|
|
22
|
+
} from "./streaming.ts";
|
|
23
|
+
import { config, type ChatMode } from "../../core/config.ts";
|
|
24
|
+
import { logger } from "../../core/logger.ts";
|
|
25
|
+
import { getContextMeterHeaders, type ContextMeterMode } from "../../services/context-meter.ts";
|
|
26
|
+
import {
|
|
27
|
+
getLogicalThreadState,
|
|
28
|
+
invalidateLogicalThreadParent,
|
|
29
|
+
RetryableQwenStreamError,
|
|
30
|
+
} from "../../services/qwen.ts";
|
|
31
|
+
import {
|
|
32
|
+
classifyRetryAction,
|
|
33
|
+
shouldRetryInvalidInputOnSameAccount,
|
|
34
|
+
} from "./retry-policy.ts";
|
|
35
|
+
import { classifyMediaModel } from "../../services/media-generation.ts";
|
|
36
|
+
import { handleMediaChatCompletion } from "./media.ts";
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
function formatTimingHeader(timings: Record<string, number>): string {
|
|
41
|
+
return Object.entries(timings)
|
|
42
|
+
.map(([key, value]) => `${key}=${Math.max(0, Math.round(value))}`)
|
|
43
|
+
.join(";");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-request chat-mode override (X-QwenProxy-Chat-Mode) falls back to the
|
|
48
|
+
* QWEN_CHAT_MODE env default. Only the two known modes are accepted; anything
|
|
49
|
+
* else silently uses the configured default.
|
|
50
|
+
*/
|
|
51
|
+
function resolveChatMode(headerValue: string | undefined): ChatMode {
|
|
52
|
+
if (headerValue === "thread" || headerValue === "temp") return headerValue;
|
|
53
|
+
return config.qwen.chatMode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function chatCompletions(c: Context) {
|
|
57
|
+
let releaseChatLock: (() => void) | null = null;
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
const timings: Record<string, number> = {};
|
|
60
|
+
const mark = (name: string, since: number) => {
|
|
61
|
+
timings[name] = Date.now() - since;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
let stepStartedAt = Date.now();
|
|
66
|
+
const parsed = await parseRequestBody(c);
|
|
67
|
+
mark("parse", stepStartedAt);
|
|
68
|
+
const {
|
|
69
|
+
body,
|
|
70
|
+
isStream,
|
|
71
|
+
systemPrompt,
|
|
72
|
+
toolInstructions,
|
|
73
|
+
prompt,
|
|
74
|
+
currentPrompt,
|
|
75
|
+
modelId,
|
|
76
|
+
enableThinking,
|
|
77
|
+
reasoningMode,
|
|
78
|
+
allFiles,
|
|
79
|
+
currentFiles,
|
|
80
|
+
shouldParseToolCalls,
|
|
81
|
+
conversationKey,
|
|
82
|
+
} = parsed;
|
|
83
|
+
|
|
84
|
+
const messages = body.messages || [];
|
|
85
|
+
const declaredTools = Array.isArray((body as any).tools)
|
|
86
|
+
? (body as any).tools
|
|
87
|
+
: [];
|
|
88
|
+
|
|
89
|
+
// Correlate arrival and dispatch: logged again on the 📤 line once the
|
|
90
|
+
// upstream stream (and its queue wait) is resolved.
|
|
91
|
+
const reqId = crypto.randomUUID().substring(0, 8);
|
|
92
|
+
const reqStartedAt = Date.now();
|
|
93
|
+
const routeLabel = c.req.header("x-qwenproxy-route") || "Chat";
|
|
94
|
+
console.log(
|
|
95
|
+
`📥 [${routeLabel}] Incoming | req=${reqId} | ${body.model} | ${messages.length} msg(s) | stream=${isStream}${declaredTools.length ? ` | ${declaredTools.length} tool(s)` : ""}${allFiles.length ? ` | ${allFiles.length} file(s)` : ""}`,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Intercept image/video generation models: they bypass the text chat flow
|
|
99
|
+
// and are handled by the native media pipeline (qwen-image-*, wan2.*).
|
|
100
|
+
const rawModel = typeof body.model === "string" ? body.model.trim() : "";
|
|
101
|
+
const mediaKind = rawModel ? classifyMediaModel(rawModel) : null;
|
|
102
|
+
if (mediaKind) {
|
|
103
|
+
return handleMediaChatCompletion({
|
|
104
|
+
c,
|
|
105
|
+
body,
|
|
106
|
+
model: rawModel,
|
|
107
|
+
kind: mediaKind,
|
|
108
|
+
isStream,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
stepStartedAt = Date.now();
|
|
113
|
+
const chatMode = resolveChatMode(c.req.header("x-qwenproxy-chat-mode"));
|
|
114
|
+
const ctx = await buildFinalContext({
|
|
115
|
+
messages,
|
|
116
|
+
systemPrompt,
|
|
117
|
+
toolInstructions,
|
|
118
|
+
prompt,
|
|
119
|
+
currentPrompt,
|
|
120
|
+
modelId,
|
|
121
|
+
enableThinking,
|
|
122
|
+
conversationKey,
|
|
123
|
+
hasExplicitConversationKey: parsed.hasExplicitConversationKey,
|
|
124
|
+
chatMode,
|
|
125
|
+
});
|
|
126
|
+
mark("context", stepStartedAt);
|
|
127
|
+
|
|
128
|
+
// Chat lock is acquired AFTER stream creation (below) to avoid holding it
|
|
129
|
+
// during account selection, retries, and anti-bot recovery which can take
|
|
130
|
+
// 30s+. Holding it here caused 190s+ lock contention cascading to all
|
|
131
|
+
// subsequent requests on the same chat.
|
|
132
|
+
mark("lock", stepStartedAt);
|
|
133
|
+
|
|
134
|
+
let finalPrompt = ctx.finalPrompt;
|
|
135
|
+
mark("thread", stepStartedAt);
|
|
136
|
+
|
|
137
|
+
const files = ctx.useThreadNative ? currentFiles : allFiles;
|
|
138
|
+
|
|
139
|
+
const msgCount =
|
|
140
|
+
ctx.useThreadNative && !ctx.isNewSession
|
|
141
|
+
? parsed.currentMessageCount
|
|
142
|
+
: parsed.messageCount;
|
|
143
|
+
|
|
144
|
+
const personalizationChars =
|
|
145
|
+
ctx.requestPersonalizationInstruction?.length ?? 0;
|
|
146
|
+
logger.debug("[chat] request routing details", {
|
|
147
|
+
model: body.model,
|
|
148
|
+
messages: msgCount,
|
|
149
|
+
promptChars: finalPrompt.length,
|
|
150
|
+
tools: declaredTools.length,
|
|
151
|
+
files: files.length,
|
|
152
|
+
personalizationChars,
|
|
153
|
+
sessionId: ctx.sessionId,
|
|
154
|
+
useThreadNative: ctx.useThreadNative,
|
|
155
|
+
isNewSession: ctx.isNewSession,
|
|
156
|
+
hasExplicitConversationKey: ctx.hasExplicitConversationKey,
|
|
157
|
+
allowThreadReuse: ctx.allowThreadReuse,
|
|
158
|
+
sessionIdentitySource: parsed.hasExplicitConversationKey
|
|
159
|
+
? typeof body.session_id === "string" &&
|
|
160
|
+
body.session_id.trim().length > 0
|
|
161
|
+
? "session_id"
|
|
162
|
+
: "conversation_id"
|
|
163
|
+
: ctx.isNewSession
|
|
164
|
+
? "none-new-chat"
|
|
165
|
+
: "implicit-continuation",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
stepStartedAt = Date.now();
|
|
169
|
+
// Full replay carries the conversation only: agent instructions ride the
|
|
170
|
+
// account-level personalization, which is confirmed on the destination
|
|
171
|
+
// account BEFORE the replayed completion is sent (an unconfirmed sync now
|
|
172
|
+
// fails the attempt instead of degrading to inline). Title generation does
|
|
173
|
+
// not sync personalization and keeps the legacy inline replay.
|
|
174
|
+
const fullPromptForRequest =
|
|
175
|
+
ctx.requestPersonalizationInstruction !== null
|
|
176
|
+
? parsed.prompt
|
|
177
|
+
: [parsed.systemPrompt, parsed.toolInstructions, parsed.prompt]
|
|
178
|
+
.filter((part) => part.trim().length > 0)
|
|
179
|
+
.join("\n\n");
|
|
180
|
+
const initialContextMode: ContextMeterMode = ctx.existingThread
|
|
181
|
+
? "delta"
|
|
182
|
+
: "full";
|
|
183
|
+
|
|
184
|
+
// Same-session latest-wins BEFORE the per-chat lock and BEFORE the stream
|
|
185
|
+
// acquisition: the client can fire the next turn while the previous stream
|
|
186
|
+
// is still open (streaming tool calls). Killing the stale generation first
|
|
187
|
+
// frees the account slot + chat lock immediately instead of queueing.
|
|
188
|
+
// onlyIfEmitted: a stream that has NOT emitted a chunk yet is protected —
|
|
189
|
+
// a parallel request (e.g. the client's title generation racing the main
|
|
190
|
+
// request) must not waste the main generation. In that case this request
|
|
191
|
+
// runs on its OWN chat (parallelEscape) instead of waiting on the main
|
|
192
|
+
// chat's lock for minutes.
|
|
193
|
+
let parallelEscape = false;
|
|
194
|
+
if (ctx.allowThreadReuse && ctx.sessionId) {
|
|
195
|
+
const superseded = abortLeaseBySessionLabel(ctx.sessionId, {
|
|
196
|
+
onlyIfEmitted: true,
|
|
197
|
+
});
|
|
198
|
+
const existingThread = getLogicalThreadState(ctx.sessionId);
|
|
199
|
+
const chatId = existingThread?.chatSessionId;
|
|
200
|
+
// Escape ONLY when an unemitted stream is actually active (a lease
|
|
201
|
+
// exists but was protected). No active lease (normal next turn) takes
|
|
202
|
+
// the regular path.
|
|
203
|
+
parallelEscape =
|
|
204
|
+
!!chatId && !superseded && hasUnemittedSessionStream(ctx.sessionId);
|
|
205
|
+
if (parallelEscape && logger.isLevelEnabled("info")) {
|
|
206
|
+
console.log(
|
|
207
|
+
`🔀 [Chat] Parallel escape | req=${reqId} | session=${ctx.sessionId} | chat=${chatId?.substring(0, 12)} | own chat`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (chatId && !parallelEscape) {
|
|
211
|
+
releaseChatLock = await acquireChatLock(chatId);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let streamResult = await acquireUpstreamStream({
|
|
216
|
+
finalPrompt,
|
|
217
|
+
fullPrompt: fullPromptForRequest,
|
|
218
|
+
isThinkingModel: ctx.isThinkingModel,
|
|
219
|
+
model: modelId,
|
|
220
|
+
reasoningMode,
|
|
221
|
+
contextModelId: modelId,
|
|
222
|
+
shouldResetUpstreamThread: ctx.shouldResetUpstreamThread,
|
|
223
|
+
allFiles: files,
|
|
224
|
+
isNewSession: ctx.isNewSession,
|
|
225
|
+
sessionId: ctx.sessionId,
|
|
226
|
+
useThreadNative: ctx.useThreadNative,
|
|
227
|
+
updateLogicalThread: parallelEscape ? false : ctx.updateLogicalThread,
|
|
228
|
+
allowThreadReuse: ctx.allowThreadReuse,
|
|
229
|
+
forceNewChat: parallelEscape,
|
|
230
|
+
preferredAccountId: undefined,
|
|
231
|
+
messageCount: msgCount,
|
|
232
|
+
fullMessageCount: parsed.messageCount,
|
|
233
|
+
toolsCount: declaredTools.length || undefined,
|
|
234
|
+
requestPersonalizationInstruction: ctx.requestPersonalizationInstruction,
|
|
235
|
+
contextMode: initialContextMode,
|
|
236
|
+
requestSignal: c.req.raw.signal,
|
|
237
|
+
messages,
|
|
238
|
+
parallelEscape,
|
|
239
|
+
chatMode,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
mark("upstream", stepStartedAt);
|
|
245
|
+
timings.preResponse = Date.now() - startedAt;
|
|
246
|
+
c.header("X-QwenProxy-Timing", formatTimingHeader(timings));
|
|
247
|
+
|
|
248
|
+
if ("error" in streamResult) {
|
|
249
|
+
if (streamResult.allOnCooldown) {
|
|
250
|
+
const err: any = new Error(
|
|
251
|
+
`All configured accounts are on cooldown. Retry in about ${Math.max(
|
|
252
|
+
1,
|
|
253
|
+
Math.ceil((streamResult.retryAfterMs ?? 0) / 1000),
|
|
254
|
+
)}s.`,
|
|
255
|
+
);
|
|
256
|
+
err.upstreamStatus = 429;
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
throw streamResult.error || new Error("All accounts failed");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const [name, value] of Object.entries(
|
|
263
|
+
getContextMeterHeaders(streamResult.tokenEstimationContext.contextMeter),
|
|
264
|
+
)) {
|
|
265
|
+
c.header(name, value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A full-context replay (account switch / missing thread parent) hides its
|
|
269
|
+
// real cost behind the thread-native delta numbers: surface it explicitly
|
|
270
|
+
// so the 📤 line shows what was actually sent upstream.
|
|
271
|
+
const replayed = streamResult.replayedFullContext === true;
|
|
272
|
+
console.log(
|
|
273
|
+
`📤 [${routeLabel}] Request | req=${reqId} | ${streamResult.activeAccountLabel} | ${body.model} | ${replayed ? parsed.messageCount : msgCount} msg(s) | ${replayed ? fullPromptForRequest.length : finalPrompt.length} chars${replayed ? " | full-replay" : ""} | chat=${streamResult.uiSessionId.substring(0, 12)}${declaredTools.length ? ` | ${declaredTools.length} tool(s)` : ""}${files.length ? ` | ${files.length} file(s)` : ""} | +${Date.now() - reqStartedAt}ms`,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const onAssistantComplete: ((event: AssistantCompleteEvent) => Promise<void> | void) | undefined = undefined;
|
|
277
|
+
|
|
278
|
+
const params = {
|
|
279
|
+
c,
|
|
280
|
+
reqId,
|
|
281
|
+
completionId: streamResult.completionId,
|
|
282
|
+
stream: streamResult.stream,
|
|
283
|
+
uiSessionId: streamResult.uiSessionId,
|
|
284
|
+
activeAccountId: streamResult.activeAccountId,
|
|
285
|
+
activeAccountLabel: streamResult.activeAccountLabel,
|
|
286
|
+
logicalSessionId: streamResult.logicalSessionId,
|
|
287
|
+
body,
|
|
288
|
+
finalPrompt,
|
|
289
|
+
userPrompt: currentPrompt || prompt,
|
|
290
|
+
shouldParseToolCalls,
|
|
291
|
+
declaredTools,
|
|
292
|
+
tokenEstimationContext: streamResult.tokenEstimationContext,
|
|
293
|
+
midStreamRetry: {
|
|
294
|
+
fullPrompt: fullPromptForRequest,
|
|
295
|
+
isThinkingModel: ctx.isThinkingModel,
|
|
296
|
+
contextModelId: modelId,
|
|
297
|
+
reasoningMode,
|
|
298
|
+
activeAccountId: streamResult.activeAccountId,
|
|
299
|
+
allFiles: files,
|
|
300
|
+
isNewSession: ctx.isNewSession,
|
|
301
|
+
sessionId: ctx.sessionId,
|
|
302
|
+
useThreadNative: ctx.useThreadNative,
|
|
303
|
+
// A parallel request (own chat) must not rebind the session thread on
|
|
304
|
+
// mid-stream recovery — the main conversation owns it.
|
|
305
|
+
updateLogicalThread: parallelEscape
|
|
306
|
+
? false
|
|
307
|
+
: ctx.updateLogicalThread,
|
|
308
|
+
parallelEscape,
|
|
309
|
+
chatMode,
|
|
310
|
+
allowThreadReuse: ctx.allowThreadReuse,
|
|
311
|
+
messageCount: msgCount,
|
|
312
|
+
fullMessageCount: parsed.messageCount,
|
|
313
|
+
toolsCount: declaredTools.length || undefined,
|
|
314
|
+
requestPersonalizationInstruction:
|
|
315
|
+
ctx.requestPersonalizationInstruction,
|
|
316
|
+
contextMode: initialContextMode as ContextMeterMode,
|
|
317
|
+
releaseAccountLease: streamResult.releaseAccountLease,
|
|
318
|
+
messages,
|
|
319
|
+
},
|
|
320
|
+
onAssistantComplete,
|
|
321
|
+
onStreamComplete: () => {
|
|
322
|
+
if (releaseChatLock) {
|
|
323
|
+
releaseChatLock();
|
|
324
|
+
releaseChatLock = null;
|
|
325
|
+
}
|
|
326
|
+
streamResult.releaseAccountLease();
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Retry loop for mid-stream/create-stream failures (generic policy)
|
|
331
|
+
let streamProcessingRetries = Math.max(0, config.retry.maxAttempts - 1);
|
|
332
|
+
let invalidInputSameAccountRetries = 0;
|
|
333
|
+
let currentStreamResult = streamResult;
|
|
334
|
+
let currentParams = params;
|
|
335
|
+
|
|
336
|
+
while (true) {
|
|
337
|
+
try {
|
|
338
|
+
return isStream
|
|
339
|
+
? await processStreamingResponse(currentParams)
|
|
340
|
+
: await processNonStreamingResponse(currentParams);
|
|
341
|
+
} catch (streamErr: any) {
|
|
342
|
+
const policy = classifyRetryAction(streamErr, {
|
|
343
|
+
requestAborted: c.req.raw.signal.aborted,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Full decision context for the outer retry loop (same rationale as
|
|
347
|
+
// the create-path policy log): the error line shows WHAT failed, this
|
|
348
|
+
// shows WHY the retry action was chosen.
|
|
349
|
+
if (logger.isLevelEnabled("info")) {
|
|
350
|
+
console.log(
|
|
351
|
+
`🧭 [Chat] Stream retry policy | req=${reqId} | reason=${policy.reason} | retryable=${policy.retryable} | switch=${policy.switchAccount} | newChat=${policy.forceNewChat} | retryAfter=${policy.retryAfterMs}ms`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (policy.reason === "corrupted_chat_history") {
|
|
356
|
+
invalidateLogicalThreadParent(ctx.sessionId);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (policy.reason === "chat_in_progress") {
|
|
360
|
+
// The same-chat settle budget AND the single bounded escalation
|
|
361
|
+
// (fresh chat + full replay) were already spent at the create path
|
|
362
|
+
// before this error surfaced. A request-level retry would restart
|
|
363
|
+
// that whole budget and replay the full context again. Surface the
|
|
364
|
+
// error; the inner loop already cleared the origin binding, so the
|
|
365
|
+
// client's own retry starts a fresh chat.
|
|
366
|
+
throw streamErr;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Prefer explicit RetryableQwenStreamError OR generic retryable policy
|
|
370
|
+
const canRetry =
|
|
371
|
+
streamProcessingRetries > 0 &&
|
|
372
|
+
policy.retryable &&
|
|
373
|
+
(streamErr instanceof RetryableQwenStreamError ||
|
|
374
|
+
config.retry.onUnknownUpstream !== false);
|
|
375
|
+
|
|
376
|
+
if (!canRetry) {
|
|
377
|
+
// Terminal (or retry budget exhausted): say WHY instead of just
|
|
378
|
+
// letting the error bubble to handleChatCompletionsError — the
|
|
379
|
+
// operator must distinguish "upstream refused" from "our retry
|
|
380
|
+
// budget ran out".
|
|
381
|
+
if (logger.isLevelEnabled("info")) {
|
|
382
|
+
console.log(
|
|
383
|
+
`⛔ [Chat] Stream retry exhausted | req=${reqId} | reason=${policy.reason} | retriesLeft=${streamProcessingRetries} | retryable=${policy.retryable} | error=${streamErr?.message?.substring(0, 150)}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
throw streamErr;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
streamProcessingRetries--;
|
|
390
|
+
console.warn(
|
|
391
|
+
`[Chat] Stream processing error, retrying with new stream | reason=${policy.reason} | ${streamErr.message?.substring(0, 150)} | retries left: ${streamProcessingRetries}`,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Recover a generic invalid_input on the same account once by
|
|
395
|
+
// creating a clean upstream chat. A second failure may rotate.
|
|
396
|
+
const retryInvalidInputOnSameAccount =
|
|
397
|
+
shouldRetryInvalidInputOnSameAccount(
|
|
398
|
+
policy.reason,
|
|
399
|
+
invalidInputSameAccountRetries > 0,
|
|
400
|
+
);
|
|
401
|
+
if (retryInvalidInputOnSameAccount) {
|
|
402
|
+
invalidInputSameAccountRetries++;
|
|
403
|
+
}
|
|
404
|
+
const switchAccount =
|
|
405
|
+
policy.switchAccount && !retryInvalidInputOnSameAccount;
|
|
406
|
+
const forceRetryNewChat = policy.forceNewChat;
|
|
407
|
+
const retryWithFullPrompt = policy.retryWithFullPrompt;
|
|
408
|
+
const retryFiles = policy.dropFiles ? [] : files;
|
|
409
|
+
|
|
410
|
+
// Do not cooldown an account when the policy is retrying it in
|
|
411
|
+
// place (temporary load shedding). A cooldown here would make the
|
|
412
|
+
// subsequent preferred-account retry skip that same account.
|
|
413
|
+
if (
|
|
414
|
+
policy.switchAccount &&
|
|
415
|
+
(policy.accountCooldownMs || policy.accountCooldownReason)
|
|
416
|
+
) {
|
|
417
|
+
const { markAccountRateLimited } =
|
|
418
|
+
await import("../../core/account-manager.ts");
|
|
419
|
+
markAccountRateLimited(
|
|
420
|
+
currentStreamResult.activeAccountId,
|
|
421
|
+
policy.accountCooldownMs,
|
|
422
|
+
policy.accountCooldownReason || "StreamRetry",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Release current chat lock and account lease before retrying
|
|
427
|
+
if (releaseChatLock) {
|
|
428
|
+
releaseChatLock();
|
|
429
|
+
releaseChatLock = null;
|
|
430
|
+
}
|
|
431
|
+
currentStreamResult.releaseAccountLease();
|
|
432
|
+
|
|
433
|
+
// Account switch always rebuilds full history; same-account retry
|
|
434
|
+
// only does so when the policy asks for forceNewChat/full prompt.
|
|
435
|
+
const needsFullPromptOnRetry =
|
|
436
|
+
retryWithFullPrompt || switchAccount || forceRetryNewChat;
|
|
437
|
+
const retryFinalPrompt = needsFullPromptOnRetry
|
|
438
|
+
? fullPromptForRequest
|
|
439
|
+
: finalPrompt;
|
|
440
|
+
const retryMessageCount = needsFullPromptOnRetry
|
|
441
|
+
? parsed.messageCount
|
|
442
|
+
: msgCount;
|
|
443
|
+
|
|
444
|
+
if (forceRetryNewChat || switchAccount) {
|
|
445
|
+
console.warn(
|
|
446
|
+
`[Chat] Retry will force a new upstream chat and resend full context | ${streamErr.message?.substring(0, 150)}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (switchAccount) {
|
|
450
|
+
console.warn(
|
|
451
|
+
`[Chat] Retry will prefer another account when available | ${streamErr.message?.substring(0, 150)}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (policy.retryAfterMs > 0) {
|
|
456
|
+
await new Promise((resolve) =>
|
|
457
|
+
setTimeout(resolve, Math.min(policy.retryAfterMs, 3000)),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Same-session latest-wins before re-acquiring: protects an
|
|
462
|
+
// unemitted generation (parallel title request) which then runs on
|
|
463
|
+
// its own chat via retryParallelEscape.
|
|
464
|
+
let retryParallelEscape = false;
|
|
465
|
+
if (ctx.allowThreadReuse && ctx.sessionId) {
|
|
466
|
+
const superseded = abortLeaseBySessionLabel(ctx.sessionId, {
|
|
467
|
+
onlyIfEmitted: true,
|
|
468
|
+
});
|
|
469
|
+
const existingThread = getLogicalThreadState(ctx.sessionId);
|
|
470
|
+
const chatId = existingThread?.chatSessionId;
|
|
471
|
+
retryParallelEscape =
|
|
472
|
+
parallelEscape ||
|
|
473
|
+
(!!chatId &&
|
|
474
|
+
!superseded &&
|
|
475
|
+
hasUnemittedSessionStream(ctx.sessionId));
|
|
476
|
+
if (retryParallelEscape && logger.isLevelEnabled("info")) {
|
|
477
|
+
console.log(
|
|
478
|
+
`🔀 [Chat] Parallel escape (retry) | req=${reqId} | session=${ctx.sessionId} | chat=${chatId?.substring(0, 12)} | own chat`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
if (chatId && !retryParallelEscape) {
|
|
482
|
+
releaseChatLock = await acquireChatLock(chatId);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Re-acquire stream with different account or a fresh upstream chat
|
|
487
|
+
const newStreamResult = await acquireUpstreamStream({
|
|
488
|
+
finalPrompt: retryFinalPrompt,
|
|
489
|
+
fullPrompt: fullPromptForRequest,
|
|
490
|
+
isThinkingModel: ctx.isThinkingModel,
|
|
491
|
+
model: modelId,
|
|
492
|
+
contextModelId: modelId,
|
|
493
|
+
shouldResetUpstreamThread: ctx.shouldResetUpstreamThread,
|
|
494
|
+
allFiles: retryFiles,
|
|
495
|
+
isNewSession: ctx.isNewSession,
|
|
496
|
+
sessionId: ctx.sessionId,
|
|
497
|
+
useThreadNative: ctx.useThreadNative,
|
|
498
|
+
updateLogicalThread: retryParallelEscape
|
|
499
|
+
? false
|
|
500
|
+
: ctx.updateLogicalThread,
|
|
501
|
+
allowThreadReuse: ctx.allowThreadReuse,
|
|
502
|
+
forceNewChat:
|
|
503
|
+
forceRetryNewChat || switchAccount || retryParallelEscape,
|
|
504
|
+
preferredAccountId: switchAccount
|
|
505
|
+
? null
|
|
506
|
+
: currentStreamResult.activeAccountId,
|
|
507
|
+
excludeAccountIds: switchAccount
|
|
508
|
+
? [currentStreamResult.activeAccountId]
|
|
509
|
+
: undefined,
|
|
510
|
+
messageCount: retryMessageCount,
|
|
511
|
+
fullMessageCount: parsed.messageCount,
|
|
512
|
+
toolsCount: declaredTools.length || undefined,
|
|
513
|
+
requestPersonalizationInstruction:
|
|
514
|
+
ctx.requestPersonalizationInstruction,
|
|
515
|
+
contextMode: needsFullPromptOnRetry ? "replay" : initialContextMode,
|
|
516
|
+
requestSignal: c.req.raw.signal,
|
|
517
|
+
messages,
|
|
518
|
+
parallelEscape: retryParallelEscape,
|
|
519
|
+
chatMode,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
if ("error" in newStreamResult) {
|
|
523
|
+
// Prefer a local preflight error over the upstream error that
|
|
524
|
+
// triggered the replay (for example, an oversized full context).
|
|
525
|
+
throw newStreamResult.error ?? streamErr;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
for (const [name, value] of Object.entries(
|
|
529
|
+
getContextMeterHeaders(
|
|
530
|
+
newStreamResult.tokenEstimationContext?.contextMeter,
|
|
531
|
+
),
|
|
532
|
+
)) {
|
|
533
|
+
c.header(name, value);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
console.log(
|
|
537
|
+
`🔄 [Chat] Request routed | ${newStreamResult.activeAccountLabel} | ${body.model} | ${retryMessageCount} msg(s) | ${retryFinalPrompt.length} chars | chat=${newStreamResult.uiSessionId.substring(0, 12)}${declaredTools.length ? ` | ${declaredTools.length} tool(s)` : ""}${files.length ? ` | ${files.length} file(s)` : ""} | retry | +${Date.now() - reqStartedAt}ms`,
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
currentStreamResult = newStreamResult;
|
|
541
|
+
currentParams = {
|
|
542
|
+
c,
|
|
543
|
+
reqId,
|
|
544
|
+
completionId: newStreamResult.completionId,
|
|
545
|
+
stream: newStreamResult.stream,
|
|
546
|
+
uiSessionId: newStreamResult.uiSessionId,
|
|
547
|
+
activeAccountId: newStreamResult.activeAccountId,
|
|
548
|
+
activeAccountLabel: newStreamResult.activeAccountLabel,
|
|
549
|
+
logicalSessionId: newStreamResult.logicalSessionId,
|
|
550
|
+
body,
|
|
551
|
+
finalPrompt: retryFinalPrompt,
|
|
552
|
+
userPrompt: currentPrompt || prompt,
|
|
553
|
+
shouldParseToolCalls,
|
|
554
|
+
declaredTools,
|
|
555
|
+
tokenEstimationContext: newStreamResult.tokenEstimationContext,
|
|
556
|
+
midStreamRetry: {
|
|
557
|
+
fullPrompt: fullPromptForRequest,
|
|
558
|
+
isThinkingModel: ctx.isThinkingModel,
|
|
559
|
+
contextModelId: modelId,
|
|
560
|
+
reasoningMode,
|
|
561
|
+
activeAccountId: newStreamResult.activeAccountId,
|
|
562
|
+
allFiles: retryFiles,
|
|
563
|
+
isNewSession: ctx.isNewSession,
|
|
564
|
+
sessionId: ctx.sessionId,
|
|
565
|
+
useThreadNative: ctx.useThreadNative,
|
|
566
|
+
updateLogicalThread: retryParallelEscape
|
|
567
|
+
? false
|
|
568
|
+
: ctx.updateLogicalThread,
|
|
569
|
+
parallelEscape: retryParallelEscape,
|
|
570
|
+
chatMode,
|
|
571
|
+
allowThreadReuse: ctx.allowThreadReuse,
|
|
572
|
+
messageCount: retryMessageCount,
|
|
573
|
+
fullMessageCount: parsed.messageCount,
|
|
574
|
+
toolsCount: declaredTools.length || undefined,
|
|
575
|
+
requestPersonalizationInstruction:
|
|
576
|
+
ctx.requestPersonalizationInstruction,
|
|
577
|
+
contextMode: needsFullPromptOnRetry
|
|
578
|
+
? "replay"
|
|
579
|
+
: initialContextMode,
|
|
580
|
+
releaseAccountLease: newStreamResult.releaseAccountLease,
|
|
581
|
+
messages,
|
|
582
|
+
},
|
|
583
|
+
onAssistantComplete,
|
|
584
|
+
onStreamComplete: () => {
|
|
585
|
+
if (releaseChatLock) {
|
|
586
|
+
releaseChatLock();
|
|
587
|
+
releaseChatLock = null;
|
|
588
|
+
}
|
|
589
|
+
newStreamResult.releaseAccountLease();
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
} catch (err) {
|
|
596
|
+
timings.preResponse = Date.now() - startedAt;
|
|
597
|
+
c.header("X-QwenProxy-Timing", formatTimingHeader(timings));
|
|
598
|
+
if (releaseChatLock) {
|
|
599
|
+
releaseChatLock();
|
|
600
|
+
releaseChatLock = null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// The client is already gone; do not turn expected cancellation into a
|
|
604
|
+
// misleading 500/internal_server_error log or retry response.
|
|
605
|
+
if (c.req.raw.signal.aborted) {
|
|
606
|
+
logger.debug("[chat] request aborted before response", {
|
|
607
|
+
error: err instanceof Error ? err.message : String(err),
|
|
608
|
+
});
|
|
609
|
+
return new Response(null, { status: 499 });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return handleChatCompletionsError(c, err);
|
|
613
|
+
} finally {
|
|
614
|
+
// Lock released via onStreamComplete when stream finishes
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export { chatCompletionsStop } from "./stop.ts";
|