qwenproxy-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +1,2710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream stream consumption: both non-streaming (JSON) and streaming (SSE)
|
|
3
|
+
* response modes. Encapsulates heartbeat, abort handling, reasoning tag
|
|
4
|
+
* sanitization, and incremental tool-call parsing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Context } from "hono";
|
|
8
|
+
import { stream as honoStream } from "hono/streaming";
|
|
9
|
+
import { buildQwenRequestHeaders } from "../../services/qwen-headers.ts";
|
|
10
|
+
import { qwenUrl } from "../../services/qwen-url.ts";
|
|
11
|
+
import {
|
|
12
|
+
requestQwenTextInBrowser,
|
|
13
|
+
updateLogicalThreadParent,
|
|
14
|
+
updateSessionParent,
|
|
15
|
+
invalidateLogicalThreadParent,
|
|
16
|
+
getQwenErrorCode,
|
|
17
|
+
RetryableQwenStreamError,
|
|
18
|
+
setToolCapNotice,
|
|
19
|
+
} from "../../services/qwen.ts";
|
|
20
|
+
import { acquireUpstreamStream } from "./account.ts";
|
|
21
|
+
import { markAccountRateLimited } from "../../core/account-manager.ts";
|
|
22
|
+
import {
|
|
23
|
+
clearTemporaryBusy,
|
|
24
|
+
markAccountTemporarilyBusy,
|
|
25
|
+
} from "../../core/account-concurrency.ts";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
classifyRetryAction,
|
|
29
|
+
shouldRetryChatInProgressOnSameAccount,
|
|
30
|
+
shouldRetryInvalidInputOnSameAccount,
|
|
31
|
+
} from "./retry-policy.ts";
|
|
32
|
+
import type { Message, OpenAIRequest, Usage } from "../../utils/types.ts";
|
|
33
|
+
import { StreamingToolParser } from "../../tools/parser.ts";
|
|
34
|
+
import {
|
|
35
|
+
getStream,
|
|
36
|
+
markStreamEmitted,
|
|
37
|
+
registerStream,
|
|
38
|
+
removeStream,
|
|
39
|
+
updateStreamSessionId,
|
|
40
|
+
updateStreamTargetResponseId,
|
|
41
|
+
} from "../../core/stream-registry.ts";
|
|
42
|
+
import { metrics } from "../../core/metrics.js";
|
|
43
|
+
import {
|
|
44
|
+
logger,
|
|
45
|
+
isToolcallDebugEnabled,
|
|
46
|
+
upstreamDebugEnabled,
|
|
47
|
+
} from "../../core/logger.js";
|
|
48
|
+
import { sendOpenAIError } from "../../api/error-helpers.js";
|
|
49
|
+
import { classifyError } from "../../api/error-classifier.js";
|
|
50
|
+
import { ClientAbortedError } from "../../core/errors.js";
|
|
51
|
+
import { config, type ChatMode } from "../../core/config.js";
|
|
52
|
+
import { parseQwenErrorPayload } from "./errors.ts";
|
|
53
|
+
import {
|
|
54
|
+
isNetworkLikeError,
|
|
55
|
+
throwFromSseUpstreamError,
|
|
56
|
+
toRetryableStreamError,
|
|
57
|
+
} from "./retry-policy.ts";
|
|
58
|
+
import {
|
|
59
|
+
logTokenEstimationSample,
|
|
60
|
+
type TokenEstimationContext,
|
|
61
|
+
} from "../../services/token-estimation-metrics.ts";
|
|
62
|
+
import {
|
|
63
|
+
enrichUsageWithContextMeter,
|
|
64
|
+
getContextMeterHeaders,
|
|
65
|
+
type ContextMeterMode,
|
|
66
|
+
} from "../../services/context-meter.ts";
|
|
67
|
+
import {
|
|
68
|
+
getIncrementalDelta,
|
|
69
|
+
formatThinkingSummaryContent,
|
|
70
|
+
shouldSuppressStreamAbort,
|
|
71
|
+
isAbortError,
|
|
72
|
+
createUsageAccumulator,
|
|
73
|
+
applyUpstreamUsage,
|
|
74
|
+
buildUsage,
|
|
75
|
+
} from "./helpers.ts";
|
|
76
|
+
|
|
77
|
+
function firstString(...values: unknown[]): string | null {
|
|
78
|
+
for (const value of values) {
|
|
79
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractChatSessionId(chunk: any): string | null {
|
|
85
|
+
const created = chunk?.["response.created"];
|
|
86
|
+
return firstString(
|
|
87
|
+
chunk?.chat_id,
|
|
88
|
+
chunk?.chatId,
|
|
89
|
+
chunk?.session_id,
|
|
90
|
+
chunk?.conversation_id,
|
|
91
|
+
chunk?.conversationId,
|
|
92
|
+
created?.chat_id,
|
|
93
|
+
created?.chatId,
|
|
94
|
+
created?.session_id,
|
|
95
|
+
created?.conversation_id,
|
|
96
|
+
created?.conversationId,
|
|
97
|
+
created?.chat?.id,
|
|
98
|
+
created?.response?.chat_id,
|
|
99
|
+
created?.response?.chat?.id,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Retry/switch policy lives in ./retry-policy.ts (generic by default).
|
|
104
|
+
|
|
105
|
+
const MAX_INITIAL_PROTOCOL_BYTES = 64 * 1024;
|
|
106
|
+
|
|
107
|
+
// Mid-stream network failures (stream died after the response was committed)
|
|
108
|
+
// cannot rotate accounts in-flight. If one account suffers several of these in a
|
|
109
|
+
// short window, mark it temporarily busy so the *next* request fails over to a
|
|
110
|
+
// healthier account instead of sticking to a flaky one.
|
|
111
|
+
const MID_STREAM_FAILURE_WINDOW_MS = 5 * 60 * 1000;
|
|
112
|
+
const midStreamNetworkFailures = new Map<string, number[]>();
|
|
113
|
+
|
|
114
|
+
function noteMidStreamNetworkFailure(accountId: string): void {
|
|
115
|
+
const threshold = config.retry.midStreamFailoverThreshold;
|
|
116
|
+
if (threshold <= 0) return;
|
|
117
|
+
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const recent = (midStreamNetworkFailures.get(accountId) ?? []).filter(
|
|
120
|
+
(t) => now - t < MID_STREAM_FAILURE_WINDOW_MS,
|
|
121
|
+
);
|
|
122
|
+
recent.push(now);
|
|
123
|
+
|
|
124
|
+
if (recent.length >= threshold) {
|
|
125
|
+
midStreamNetworkFailures.delete(accountId);
|
|
126
|
+
markAccountTemporarilyBusy(accountId, config.retry.midStreamFailoverBusyMs);
|
|
127
|
+
logger.warn(
|
|
128
|
+
"[Chat] Account marked temporarily busy after repeated mid-stream network failures; next request will rotate",
|
|
129
|
+
{
|
|
130
|
+
accountId,
|
|
131
|
+
failures: recent.length,
|
|
132
|
+
busyMs: config.retry.midStreamFailoverBusyMs,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
} else {
|
|
136
|
+
midStreamNetworkFailures.set(accountId, recent);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasSseProtocolStart(buffer: string): boolean {
|
|
141
|
+
// Skip leading whitespace without allocating a trimmed copy, then test the
|
|
142
|
+
// first significant char: ":" (SSE comment) or "data:" prefix.
|
|
143
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
144
|
+
const ch = buffer.charCodeAt(i);
|
|
145
|
+
if (ch === 32 || ch === 9 || ch === 10 || ch === 13) continue;
|
|
146
|
+
return ch === 58 || buffer.startsWith("data:", i);
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function throwParsedUpstreamError(
|
|
152
|
+
error: NonNullable<ReturnType<typeof parseQwenErrorPayload>>,
|
|
153
|
+
): never {
|
|
154
|
+
throwFromSseUpstreamError(error.code, error.details);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface AssistantCompleteEvent {
|
|
158
|
+
sessionId: string | null;
|
|
159
|
+
accountId: string;
|
|
160
|
+
chatSessionId: string;
|
|
161
|
+
parentId: string | null;
|
|
162
|
+
responseId: string | null;
|
|
163
|
+
userPrompt: string;
|
|
164
|
+
finalPrompt: string;
|
|
165
|
+
assistantContent: string;
|
|
166
|
+
reasoningContent?: string;
|
|
167
|
+
usage: Usage;
|
|
168
|
+
finishReason: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type AssistantCompleteHandler = (
|
|
172
|
+
event: AssistantCompleteEvent,
|
|
173
|
+
) => Promise<void> | void;
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
export interface StreamProcessingParams {
|
|
178
|
+
c: Context;
|
|
179
|
+
/** Short request id for log correlation (from the 📥 Incoming line). */
|
|
180
|
+
reqId?: string;
|
|
181
|
+
completionId: string;
|
|
182
|
+
stream: ReadableStream;
|
|
183
|
+
uiSessionId: string;
|
|
184
|
+
activeAccountId: string;
|
|
185
|
+
activeAccountLabel?: string;
|
|
186
|
+
logicalSessionId: string | null;
|
|
187
|
+
body: OpenAIRequest & { stream_options?: { include_usage?: boolean } };
|
|
188
|
+
finalPrompt: string;
|
|
189
|
+
userPrompt: string;
|
|
190
|
+
shouldParseToolCalls: boolean;
|
|
191
|
+
declaredTools: any[];
|
|
192
|
+
tokenEstimationContext?: TokenEstimationContext;
|
|
193
|
+
midStreamRetry?: {
|
|
194
|
+
fullPrompt: string;
|
|
195
|
+
isThinkingModel: boolean;
|
|
196
|
+
contextModelId?: string;
|
|
197
|
+
reasoningMode?: "auto" | "thinking" | "fast";
|
|
198
|
+
activeAccountId?: string;
|
|
199
|
+
allFiles: any[];
|
|
200
|
+
isNewSession: boolean;
|
|
201
|
+
sessionId: string | null;
|
|
202
|
+
useThreadNative: boolean;
|
|
203
|
+
updateLogicalThread: boolean;
|
|
204
|
+
/** Parallel request (own chat): recovery must not kill or rebind. */
|
|
205
|
+
parallelEscape?: boolean;
|
|
206
|
+
/** "thread" (reuse upstream chat) or "temp" (new ephemeral chat per request). */
|
|
207
|
+
chatMode: ChatMode;
|
|
208
|
+
allowThreadReuse: boolean;
|
|
209
|
+
messageCount: number;
|
|
210
|
+
fullMessageCount: number;
|
|
211
|
+
toolsCount?: number;
|
|
212
|
+
requestPersonalizationInstruction?: string | null;
|
|
213
|
+
contextMode?: ContextMeterMode;
|
|
214
|
+
releaseAccountLease: () => void;
|
|
215
|
+
messages?: Message[];
|
|
216
|
+
/** How many malformed-tool-call auto-retries have already run (0-based). */
|
|
217
|
+
malformedRetryCount?: number;
|
|
218
|
+
};
|
|
219
|
+
onAssistantComplete?: AssistantCompleteHandler;
|
|
220
|
+
onStreamComplete?: () => void;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function scheduleAssistantComplete(
|
|
224
|
+
handler: AssistantCompleteHandler | undefined,
|
|
225
|
+
event: AssistantCompleteEvent,
|
|
226
|
+
): void {
|
|
227
|
+
if (!handler) return;
|
|
228
|
+
void Promise.resolve()
|
|
229
|
+
.then(() => handler(event))
|
|
230
|
+
.catch((error) => {
|
|
231
|
+
logger.warn("[chat] assistant completion callback failed", {
|
|
232
|
+
sessionId: event.sessionId,
|
|
233
|
+
chatSessionId: event.chatSessionId,
|
|
234
|
+
responseId: event.responseId,
|
|
235
|
+
error: error instanceof Error ? error.message : String(error),
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Non-streaming (JSON response) ─────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export async function processNonStreamingResponse(
|
|
243
|
+
params: StreamProcessingParams,
|
|
244
|
+
): Promise<Response> {
|
|
245
|
+
const {
|
|
246
|
+
c,
|
|
247
|
+
completionId,
|
|
248
|
+
stream,
|
|
249
|
+
uiSessionId,
|
|
250
|
+
activeAccountId,
|
|
251
|
+
logicalSessionId,
|
|
252
|
+
body,
|
|
253
|
+
finalPrompt,
|
|
254
|
+
userPrompt,
|
|
255
|
+
shouldParseToolCalls,
|
|
256
|
+
declaredTools,
|
|
257
|
+
tokenEstimationContext,
|
|
258
|
+
midStreamRetry,
|
|
259
|
+
onAssistantComplete,
|
|
260
|
+
onStreamComplete,
|
|
261
|
+
} = params;
|
|
262
|
+
const reqId = params.reqId ?? completionId.substring(0, 8);
|
|
263
|
+
const streamStartedAt = Date.now();
|
|
264
|
+
let currentTokenEstimationContext = tokenEstimationContext;
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const reader = stream.getReader();
|
|
268
|
+
const decoder = new TextDecoder();
|
|
269
|
+
|
|
270
|
+
let lastThinkingSummary = "";
|
|
271
|
+
let lastThinkingSummaryLength = 0;
|
|
272
|
+
let lastThinkingSummarySuffix = "";
|
|
273
|
+
let reasoningBuffer = "";
|
|
274
|
+
let lastRawContent = "";
|
|
275
|
+
let lastRawContentLength = 0;
|
|
276
|
+
let lastRawContentSuffix = "";
|
|
277
|
+
let finalContent = "";
|
|
278
|
+
let targetResponseId: string | null = null;
|
|
279
|
+
let pendingParentId: string | null = null;
|
|
280
|
+
let currentUiSessionId = uiSessionId;
|
|
281
|
+
const toolParser = shouldParseToolCalls
|
|
282
|
+
? new StreamingToolParser(declaredTools, {
|
|
283
|
+
maxToolCallsPerTurn: config.retry.maxToolCallsPerTurn,
|
|
284
|
+
})
|
|
285
|
+
: null;
|
|
286
|
+
const toolCallsOut: any[] = [];
|
|
287
|
+
let buffer = "";
|
|
288
|
+
let protocolBuffer = "";
|
|
289
|
+
let protocolProbeBytes = 0;
|
|
290
|
+
let sawSseProtocol = false;
|
|
291
|
+
const usageAccumulator = createUsageAccumulator(0);
|
|
292
|
+
|
|
293
|
+
const rememberSession = (sessionId: string | null) => {
|
|
294
|
+
if (!sessionId || sessionId === currentUiSessionId) return;
|
|
295
|
+
currentUiSessionId = sessionId;
|
|
296
|
+
updateStreamSessionId(completionId, sessionId);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const rememberParent = (parentId: string) => {
|
|
300
|
+
if (!currentUiSessionId) return;
|
|
301
|
+
updateSessionParent(currentUiSessionId, parentId, activeAccountId);
|
|
302
|
+
updateLogicalThreadParent(
|
|
303
|
+
logicalSessionId,
|
|
304
|
+
parentId,
|
|
305
|
+
activeAccountId,
|
|
306
|
+
currentUiSessionId,
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const consumeAnswerText = (textChunk: string) => {
|
|
311
|
+
if (!toolParser) {
|
|
312
|
+
finalContent += textChunk;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const { text, toolCalls } = toolParser.feed(textChunk);
|
|
317
|
+
if (text) {
|
|
318
|
+
finalContent += text;
|
|
319
|
+
}
|
|
320
|
+
if (isToolcallDebugEnabled() && (text || toolCalls.length > 0)) {
|
|
321
|
+
logger.debug("[chat] non-stream: parser feed result", {
|
|
322
|
+
textLength: text.length,
|
|
323
|
+
textPreview: text.substring(0, 100),
|
|
324
|
+
toolCallsCount: toolCalls.length,
|
|
325
|
+
toolCallNames: toolCalls.map((tc) => tc.name),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
for (const tc of toolCalls) {
|
|
329
|
+
toolCallsOut.push({
|
|
330
|
+
id: tc.id,
|
|
331
|
+
type: "function",
|
|
332
|
+
function: {
|
|
333
|
+
name: tc.name,
|
|
334
|
+
arguments: JSON.stringify(tc.arguments),
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (isToolcallDebugEnabled()) {
|
|
339
|
+
logger.debug("[chat] non-stream: tool_call collected", {
|
|
340
|
+
id: tc.id,
|
|
341
|
+
name: tc.name,
|
|
342
|
+
argsKeys: Object.keys(tc.arguments),
|
|
343
|
+
totalCollected: toolCallsOut.length,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
while (true) {
|
|
352
|
+
const { done, value } = await reader.read();
|
|
353
|
+
if (done) break;
|
|
354
|
+
|
|
355
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
356
|
+
if (!sawSseProtocol) {
|
|
357
|
+
protocolBuffer += decoded;
|
|
358
|
+
sawSseProtocol = hasSseProtocolStart(protocolBuffer);
|
|
359
|
+
// Count bytes incrementally instead of re-scanning the growing buffer.
|
|
360
|
+
protocolProbeBytes += value.byteLength;
|
|
361
|
+
if (!sawSseProtocol && protocolProbeBytes > MAX_INITIAL_PROTOCOL_BYTES) {
|
|
362
|
+
throw toRetryableStreamError(
|
|
363
|
+
"non_sse_response",
|
|
364
|
+
"Qwen did not start an SSE response before the protocol probe limit.",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
buffer += decoded;
|
|
370
|
+
let lineStart = 0;
|
|
371
|
+
let lineEnd = buffer.indexOf("\n", lineStart);
|
|
372
|
+
|
|
373
|
+
for (; lineEnd !== -1; lineEnd = buffer.indexOf("\n", lineStart)) {
|
|
374
|
+
let dataStr = "";
|
|
375
|
+
if (buffer.startsWith("data:", lineStart)) {
|
|
376
|
+
let s = lineStart + 5;
|
|
377
|
+
if (buffer.charCodeAt(s) === 32) s++;
|
|
378
|
+
let e = lineEnd;
|
|
379
|
+
if (e > s && buffer.charCodeAt(e - 1) === 13) e--;
|
|
380
|
+
dataStr = buffer.substring(s, e);
|
|
381
|
+
}
|
|
382
|
+
lineStart = lineEnd + 1;
|
|
383
|
+
if (!dataStr) continue;
|
|
384
|
+
if (dataStr === "[DONE]") continue;
|
|
385
|
+
|
|
386
|
+
if (upstreamDebugEnabled) {
|
|
387
|
+
console.log(`📤 [Upstream] Chunk | ${dataStr.substring(0, 500)}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const chunk = JSON.parse(dataStr);
|
|
392
|
+
rememberSession(extractChatSessionId(chunk));
|
|
393
|
+
|
|
394
|
+
// Generic upstream SSE error handling (retry/switch via policy)
|
|
395
|
+
if (chunk.error) {
|
|
396
|
+
const errDetails =
|
|
397
|
+
chunk.error.details ||
|
|
398
|
+
chunk.error.message ||
|
|
399
|
+
JSON.stringify(chunk.error);
|
|
400
|
+
const errCode = chunk.error.code || "upstream_error";
|
|
401
|
+
throwFromSseUpstreamError(errCode, errDetails);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (
|
|
405
|
+
chunk["response.created"] &&
|
|
406
|
+
chunk["response.created"].response_id
|
|
407
|
+
) {
|
|
408
|
+
if (chunk["response.created"].chat_id) {
|
|
409
|
+
rememberSession(chunk["response.created"].chat_id);
|
|
410
|
+
}
|
|
411
|
+
if (!targetResponseId) {
|
|
412
|
+
targetResponseId = chunk["response.created"].response_id;
|
|
413
|
+
}
|
|
414
|
+
// Commit the parent only after the whole response succeeds.
|
|
415
|
+
pendingParentId = chunk["response.created"].response_id;
|
|
416
|
+
// Qwen-internal metadata event — never forward to the client.
|
|
417
|
+
continue;
|
|
418
|
+
} else if (chunk.response_id && !targetResponseId) {
|
|
419
|
+
targetResponseId = chunk.response_id;
|
|
420
|
+
pendingParentId = chunk.response_id;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
applyUpstreamUsage(usageAccumulator, chunk.usage);
|
|
424
|
+
|
|
425
|
+
let vStr = "";
|
|
426
|
+
let foundStr = false;
|
|
427
|
+
let isThinkingChunk = false;
|
|
428
|
+
|
|
429
|
+
if (
|
|
430
|
+
chunk.choices &&
|
|
431
|
+
chunk.choices[0] &&
|
|
432
|
+
chunk.choices[0].delta &&
|
|
433
|
+
(targetResponseId === null ||
|
|
434
|
+
chunk.response_id === targetResponseId)
|
|
435
|
+
) {
|
|
436
|
+
const delta = chunk.choices[0].delta;
|
|
437
|
+
|
|
438
|
+
if (delta.phase === "thinking_summary") {
|
|
439
|
+
isThinkingChunk = true;
|
|
440
|
+
const formattedSummary = formatThinkingSummaryContent(delta);
|
|
441
|
+
if (formattedSummary) {
|
|
442
|
+
const result = getIncrementalDelta(
|
|
443
|
+
lastThinkingSummary,
|
|
444
|
+
formattedSummary,
|
|
445
|
+
lastThinkingSummaryLength,
|
|
446
|
+
lastThinkingSummarySuffix,
|
|
447
|
+
);
|
|
448
|
+
vStr = result.delta;
|
|
449
|
+
lastThinkingSummary = result.matchedContent;
|
|
450
|
+
lastThinkingSummaryLength = result.contentLength;
|
|
451
|
+
lastThinkingSummarySuffix = result.contentSuffix;
|
|
452
|
+
if (vStr) {
|
|
453
|
+
foundStr = true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} else if (delta.phase === "answer") {
|
|
457
|
+
isThinkingChunk = false;
|
|
458
|
+
if (delta.content !== undefined) {
|
|
459
|
+
const newContent = delta.content || "";
|
|
460
|
+
const result = getIncrementalDelta(
|
|
461
|
+
lastRawContent,
|
|
462
|
+
newContent,
|
|
463
|
+
lastRawContentLength,
|
|
464
|
+
lastRawContentSuffix,
|
|
465
|
+
);
|
|
466
|
+
vStr = result.delta;
|
|
467
|
+
if (vStr) {
|
|
468
|
+
lastRawContent = result.matchedContent;
|
|
469
|
+
lastRawContentLength = result.contentLength;
|
|
470
|
+
lastRawContentSuffix = result.contentSuffix;
|
|
471
|
+
foundStr = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (foundStr && vStr !== "") {
|
|
478
|
+
if (vStr === "FINISHED") continue;
|
|
479
|
+
if (isThinkingChunk) {
|
|
480
|
+
reasoningBuffer += vStr;
|
|
481
|
+
} else {
|
|
482
|
+
consumeAnswerText(vStr);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} catch (_e) {
|
|
486
|
+
// Re-throw policy-driven retry errors for outer retry loop
|
|
487
|
+
if (_e instanceof RetryableQwenStreamError) {
|
|
488
|
+
throw _e;
|
|
489
|
+
}
|
|
490
|
+
// Log warning for large chunks that fail to parse
|
|
491
|
+
if (dataStr.length > 10) {
|
|
492
|
+
console.warn(
|
|
493
|
+
`[Chat] SSE parse error for chunk (${dataStr.length} chars):`,
|
|
494
|
+
(_e as Error).message,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
buffer = lineStart > 0 ? buffer.slice(lineStart) : buffer;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (!sawSseProtocol) {
|
|
504
|
+
const upstreamError = parseQwenErrorPayload(protocolBuffer);
|
|
505
|
+
if (upstreamError) {
|
|
506
|
+
throwParsedUpstreamError(upstreamError);
|
|
507
|
+
}
|
|
508
|
+
throw toRetryableStreamError(
|
|
509
|
+
"non_sse_response",
|
|
510
|
+
"Qwen ended the response before emitting an SSE event.",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const upstreamError = parseQwenErrorPayload(buffer);
|
|
515
|
+
if (upstreamError) {
|
|
516
|
+
throwParsedUpstreamError(upstreamError);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
const remainingParsed = toolParser
|
|
522
|
+
? toolParser.flush()
|
|
523
|
+
: { text: "", toolCalls: [] };
|
|
524
|
+
const { text: remainingText, toolCalls: remainingToolCalls } =
|
|
525
|
+
remainingParsed;
|
|
526
|
+
|
|
527
|
+
if (toolParser && isToolcallDebugEnabled()) {
|
|
528
|
+
logger.debug("[chat] non-stream: parser flush result", {
|
|
529
|
+
remainingTextLength: remainingText?.length || 0,
|
|
530
|
+
remainingToolCallsCount: remainingToolCalls.length,
|
|
531
|
+
remainingToolCallNames: remainingToolCalls.map((tc) => tc.name),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (remainingText) {
|
|
536
|
+
finalContent += remainingText;
|
|
537
|
+
}
|
|
538
|
+
for (const tc of remainingToolCalls) {
|
|
539
|
+
toolCallsOut.push({
|
|
540
|
+
id: tc.id,
|
|
541
|
+
type: "function",
|
|
542
|
+
function: {
|
|
543
|
+
name: tc.name,
|
|
544
|
+
arguments: JSON.stringify(tc.arguments),
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (isToolcallDebugEnabled()) {
|
|
550
|
+
logger.debug("[chat] non-stream: final toolcall summary", {
|
|
551
|
+
totalToolCalls: toolCallsOut.length,
|
|
552
|
+
toolCallNames: toolCallsOut.map((tc: any) => tc.function?.name),
|
|
553
|
+
contentLength: finalContent.length,
|
|
554
|
+
hasReasoning: !!reasoningBuffer,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const usage = enrichUsageWithContextMeter(
|
|
559
|
+
buildUsage(usageAccumulator),
|
|
560
|
+
currentTokenEstimationContext?.contextMeter,
|
|
561
|
+
);
|
|
562
|
+
for (const [name, value] of Object.entries(
|
|
563
|
+
getContextMeterHeaders((usage as any).context_meter),
|
|
564
|
+
)) {
|
|
565
|
+
c.header(name, value);
|
|
566
|
+
}
|
|
567
|
+
const message: any = {
|
|
568
|
+
role: "assistant",
|
|
569
|
+
content: toolCallsOut.length ? null : finalContent,
|
|
570
|
+
};
|
|
571
|
+
if (reasoningBuffer) message.reasoning_content = reasoningBuffer;
|
|
572
|
+
if (toolCallsOut.length) {
|
|
573
|
+
message.tool_calls = toolCallsOut;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const finishReason = toolCallsOut.length ? "tool_calls" : "stop";
|
|
577
|
+
|
|
578
|
+
// Auto-retry if all tool calls were malformed (no successful tool calls)
|
|
579
|
+
const allToolsFailed = toolParser && toolParser.getMalformedToolCalls().length > 0 && toolCallsOut.length === 0;
|
|
580
|
+
const malformedRetryCount = midStreamRetry?.malformedRetryCount ?? 0;
|
|
581
|
+
if (
|
|
582
|
+
allToolsFailed &&
|
|
583
|
+
config.retry.autoRetryMalformedTools !== false &&
|
|
584
|
+
midStreamRetry &&
|
|
585
|
+
malformedRetryCount < config.retry.autoRetryMalformedToolsMax
|
|
586
|
+
) {
|
|
587
|
+
const malformedCalls = toolParser.getMalformedToolCalls();
|
|
588
|
+
const malformedCount = malformedCalls.length;
|
|
589
|
+
|
|
590
|
+
// Build detailed error message with available tools list
|
|
591
|
+
const undeclaredNames = malformedCalls
|
|
592
|
+
.flatMap((mc) => mc.undeclaredNames || [])
|
|
593
|
+
.filter((name, index, self) => self.indexOf(name) === index);
|
|
594
|
+
|
|
595
|
+
const availableToolNames = declaredTools
|
|
596
|
+
.map((t: any) => t.type === "function" ? t.function?.name : t.name)
|
|
597
|
+
.filter((n: string | undefined): n is string => !!n);
|
|
598
|
+
const toolsHint = availableToolNames.length > 0
|
|
599
|
+
? `\n\nAvailable tools: ${availableToolNames.join(", ")}`
|
|
600
|
+
: "";
|
|
601
|
+
|
|
602
|
+
let errorMessage: string;
|
|
603
|
+
if (undeclaredNames.length > 0) {
|
|
604
|
+
errorMessage = `Your previous ${malformedCount} tool call(s) used undeclared tool names: ${undeclaredNames.join(", ")}. Only declared tools can be executed. Please retry with valid tool names.${toolsHint}`;
|
|
605
|
+
} else {
|
|
606
|
+
const previews = malformedCalls
|
|
607
|
+
.slice(0, 3)
|
|
608
|
+
.map((mc) => mc.contentPreview?.substring(0, 100) || "(empty)")
|
|
609
|
+
.join("\n - ");
|
|
610
|
+
errorMessage = `Your previous ${malformedCount} tool call(s) were malformed and could not be executed. The JSON was invalid or truncated. Please retry with valid JSON.\n\nFailed attempt(s):\n - ${previews}${toolsHint}`;
|
|
611
|
+
}
|
|
612
|
+
if (isToolcallDebugEnabled()) {
|
|
613
|
+
logger.debug("[chat] non-stream: auto-retrying malformed tool calls", {
|
|
614
|
+
malformedCount,
|
|
615
|
+
undeclaredNames,
|
|
616
|
+
completionId,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
const retryPrompt = `${midStreamRetry.fullPrompt}\n\n[SYSTEM CORRECTION]\n${errorMessage}\n\nPlease retry your tool call(s) with correct JSON and valid tool names from the available tools list above.`;
|
|
620
|
+
|
|
621
|
+
// Release current stream and lease
|
|
622
|
+
try {
|
|
623
|
+
await stream.cancel();
|
|
624
|
+
} catch (cancelErr) {
|
|
625
|
+
// Ignore cancel errors
|
|
626
|
+
}
|
|
627
|
+
midStreamRetry.releaseAccountLease();
|
|
628
|
+
|
|
629
|
+
// Acquire new stream for retry - keep same account, force new chat
|
|
630
|
+
const newStreamResult = await acquireUpstreamStream({
|
|
631
|
+
finalPrompt: retryPrompt,
|
|
632
|
+
fullPrompt: retryPrompt,
|
|
633
|
+
isThinkingModel: midStreamRetry.isThinkingModel,
|
|
634
|
+
model: body.model,
|
|
635
|
+
reasoningMode: midStreamRetry.reasoningMode,
|
|
636
|
+
shouldResetUpstreamThread: true,
|
|
637
|
+
allFiles: midStreamRetry.allFiles,
|
|
638
|
+
isNewSession: midStreamRetry.isNewSession,
|
|
639
|
+
sessionId: midStreamRetry.sessionId,
|
|
640
|
+
useThreadNative: midStreamRetry.useThreadNative,
|
|
641
|
+
updateLogicalThread: midStreamRetry.updateLogicalThread,
|
|
642
|
+
parallelEscape: midStreamRetry.parallelEscape,
|
|
643
|
+
allowThreadReuse: midStreamRetry.allowThreadReuse,
|
|
644
|
+
chatMode: midStreamRetry.chatMode,
|
|
645
|
+
forceNewChat: true,
|
|
646
|
+
preferredAccountId: midStreamRetry.activeAccountId,
|
|
647
|
+
excludeAccountIds: undefined,
|
|
648
|
+
messageCount: midStreamRetry.messageCount,
|
|
649
|
+
fullMessageCount: midStreamRetry.fullMessageCount,
|
|
650
|
+
toolsCount: midStreamRetry.toolsCount,
|
|
651
|
+
requestPersonalizationInstruction: midStreamRetry.requestPersonalizationInstruction,
|
|
652
|
+
contextMode: "replay",
|
|
653
|
+
requestSignal: c.req.raw.signal,
|
|
654
|
+
messages: midStreamRetry.messages,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if ("error" in newStreamResult) {
|
|
658
|
+
// Client abort during retry acquisition is expected, not an error.
|
|
659
|
+
if (newStreamResult.error instanceof ClientAbortedError) {
|
|
660
|
+
logger.debug(
|
|
661
|
+
"[chat] non-stream: auto-retry aborted by client (silent)",
|
|
662
|
+
{
|
|
663
|
+
completionId,
|
|
664
|
+
},
|
|
665
|
+
);
|
|
666
|
+
return sendOpenAIError(c, newStreamResult.error);
|
|
667
|
+
}
|
|
668
|
+
// Retry failed, return original error
|
|
669
|
+
logger.error("[chat] non-stream: auto-retry failed to acquire stream", {
|
|
670
|
+
error: newStreamResult.error?.message,
|
|
671
|
+
completionId,
|
|
672
|
+
});
|
|
673
|
+
return sendOpenAIError(c, newStreamResult.error);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Critical detail 3: non-streaming has no clientDisconnected flag; the
|
|
677
|
+
// guard is the request signal. If the client aborted while acquiring the
|
|
678
|
+
// retry stream, release the fresh lease (idempotent) and bail so the
|
|
679
|
+
// lease is not orphaned.
|
|
680
|
+
if (c.req.raw.signal.aborted) {
|
|
681
|
+
newStreamResult.releaseAccountLease();
|
|
682
|
+
return sendOpenAIError(
|
|
683
|
+
c,
|
|
684
|
+
new Error("client aborted during malformed-tool retry"),
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
console.log(`🔄 [Chat] Auto-retry (${malformedRetryCount + 1}/${config.retry.autoRetryMalformedToolsMax}) | ${newStreamResult.activeAccountLabel} | ${body.model} | chat=${newStreamResult.uiSessionId.substring(0, 12)} | reason=malformed_tool_calls`);
|
|
689
|
+
|
|
690
|
+
// Process the new stream. Propagate the retry context with an incremented
|
|
691
|
+
// counter so a subsequent malformed response can retry again up to
|
|
692
|
+
// autoRetryMalformedToolsMax, and hand over the fresh lease/account.
|
|
693
|
+
return processNonStreamingResponse({
|
|
694
|
+
...params,
|
|
695
|
+
stream: newStreamResult.stream,
|
|
696
|
+
uiSessionId: newStreamResult.uiSessionId,
|
|
697
|
+
activeAccountId: newStreamResult.activeAccountId,
|
|
698
|
+
activeAccountLabel: newStreamResult.activeAccountLabel,
|
|
699
|
+
finalPrompt: retryPrompt,
|
|
700
|
+
tokenEstimationContext: newStreamResult.tokenEstimationContext,
|
|
701
|
+
midStreamRetry: {
|
|
702
|
+
...midStreamRetry,
|
|
703
|
+
malformedRetryCount: malformedRetryCount + 1,
|
|
704
|
+
activeAccountId: newStreamResult.activeAccountId,
|
|
705
|
+
releaseAccountLease: newStreamResult.releaseAccountLease,
|
|
706
|
+
},
|
|
707
|
+
onStreamComplete: () => {
|
|
708
|
+
newStreamResult.releaseAccountLease();
|
|
709
|
+
onStreamComplete?.();
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Tool calls dropped and NOT recovered by the auto-retry. Keep this out of
|
|
715
|
+
// the user-visible response entirely: the retry path already told Qwen what
|
|
716
|
+
// went wrong in the upstream prompt, and an echoed [WARNING] text block
|
|
717
|
+
// would leak bridge editorializing into the client's UI.
|
|
718
|
+
if (
|
|
719
|
+
toolParser &&
|
|
720
|
+
(toolParser.getMalformedToolCalls().length > 0 ||
|
|
721
|
+
toolParser.getCappedToolCalls().length > 0)
|
|
722
|
+
) {
|
|
723
|
+
const malformedCalls = toolParser.getMalformedToolCalls();
|
|
724
|
+
const undeclaredNames = malformedCalls
|
|
725
|
+
.map((mc) => mc.undeclaredNames)
|
|
726
|
+
.flat()
|
|
727
|
+
.filter((n): n is string => !!n);
|
|
728
|
+
const cappedToolNames = toolParser
|
|
729
|
+
.getCappedToolCalls()
|
|
730
|
+
.map((c) => c.toolName);
|
|
731
|
+
|
|
732
|
+
logger.warn(
|
|
733
|
+
"[chat] non-stream: tool calls not retried (malformed or over per-turn cap)",
|
|
734
|
+
{
|
|
735
|
+
malformedCount: malformedCalls.length,
|
|
736
|
+
cappedCount: cappedToolNames.length,
|
|
737
|
+
cappedToolNames,
|
|
738
|
+
undeclaredNames,
|
|
739
|
+
completionId,
|
|
740
|
+
},
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (isToolcallDebugEnabled()) {
|
|
745
|
+
logger.debug("[chat] non-stream: sending response", {
|
|
746
|
+
completionId,
|
|
747
|
+
finishReason,
|
|
748
|
+
totalToolCalls: toolCallsOut.length,
|
|
749
|
+
contentLength: message.content?.length || 0,
|
|
750
|
+
hasReasoning: !!message.reasoning_content,
|
|
751
|
+
usage,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
logTokenEstimationSample({
|
|
756
|
+
model: body.model,
|
|
757
|
+
finalPrompt,
|
|
758
|
+
userPrompt,
|
|
759
|
+
assistantContent: finalContent,
|
|
760
|
+
reasoningContent: reasoningBuffer || undefined,
|
|
761
|
+
usage,
|
|
762
|
+
mode: "non-stream",
|
|
763
|
+
context: currentTokenEstimationContext,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// The response was fully processed: persist the next-turn parent.
|
|
767
|
+
if (pendingParentId) {
|
|
768
|
+
rememberParent(pendingParentId);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
scheduleAssistantComplete(onAssistantComplete, {
|
|
772
|
+
sessionId: logicalSessionId,
|
|
773
|
+
accountId: activeAccountId,
|
|
774
|
+
chatSessionId: currentUiSessionId,
|
|
775
|
+
parentId: pendingParentId,
|
|
776
|
+
responseId: targetResponseId,
|
|
777
|
+
userPrompt,
|
|
778
|
+
finalPrompt,
|
|
779
|
+
assistantContent: finalContent,
|
|
780
|
+
reasoningContent: reasoningBuffer || undefined,
|
|
781
|
+
usage,
|
|
782
|
+
finishReason,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
return c.json({
|
|
786
|
+
id: completionId,
|
|
787
|
+
object: "chat.completion",
|
|
788
|
+
created: Math.floor(Date.now() / 1000),
|
|
789
|
+
model: body.model,
|
|
790
|
+
choices: [
|
|
791
|
+
{
|
|
792
|
+
index: 0,
|
|
793
|
+
message,
|
|
794
|
+
logprobs: null,
|
|
795
|
+
finish_reason: finishReason,
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
usage,
|
|
799
|
+
});
|
|
800
|
+
} finally {
|
|
801
|
+
if (isToolcallDebugEnabled()) {
|
|
802
|
+
logger.debug("[chat] non-stream: cleanup", { completionId });
|
|
803
|
+
}
|
|
804
|
+
if (logger.isLevelEnabled("info")) {
|
|
805
|
+
console.log(
|
|
806
|
+
`⏱️ [Chat] Done (non-stream) | req=${reqId} | ${Date.now() - streamStartedAt}ms`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
removeStream(completionId);
|
|
810
|
+
if (onStreamComplete) onStreamComplete();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ─── Streaming (SSE) ───────────────────────────────────────────────────────────
|
|
815
|
+
|
|
816
|
+
export async function processStreamingResponse(
|
|
817
|
+
params: StreamProcessingParams,
|
|
818
|
+
): Promise<Response> {
|
|
819
|
+
const {
|
|
820
|
+
c,
|
|
821
|
+
completionId,
|
|
822
|
+
stream,
|
|
823
|
+
uiSessionId,
|
|
824
|
+
activeAccountId,
|
|
825
|
+
activeAccountLabel = activeAccountId,
|
|
826
|
+
logicalSessionId,
|
|
827
|
+
body,
|
|
828
|
+
finalPrompt,
|
|
829
|
+
userPrompt,
|
|
830
|
+
shouldParseToolCalls,
|
|
831
|
+
declaredTools,
|
|
832
|
+
tokenEstimationContext,
|
|
833
|
+
midStreamRetry,
|
|
834
|
+
onAssistantComplete,
|
|
835
|
+
onStreamComplete,
|
|
836
|
+
} = params;
|
|
837
|
+
const reqId = params.reqId ?? completionId.substring(0, 8);
|
|
838
|
+
const streamStartedAt = Date.now();
|
|
839
|
+
let firstChunkAt: number | null = null;
|
|
840
|
+
// Last model delta handed to the client. Stream done reports the gap between
|
|
841
|
+
// this and the teardown (tail): a large tail means the visible response had
|
|
842
|
+
// finished long before the upstream terminal event arrived (thinking-model
|
|
843
|
+
// terminal lag), which reads as "loading after the answer" on the client.
|
|
844
|
+
let lastDeltaAt: number | null = null;
|
|
845
|
+
let currentTokenEstimationContext = tokenEstimationContext;
|
|
846
|
+
|
|
847
|
+
// Disable Nagle's algorithm on the underlying TCP socket to eliminate 40ms delayed
|
|
848
|
+
// ACK latency on incremental SSE packet delivery.
|
|
849
|
+
const socket =
|
|
850
|
+
(c.env as any)?.incoming?.socket || (c.req.raw as any)?.socket;
|
|
851
|
+
if (socket && typeof socket.setNoDelay === "function") {
|
|
852
|
+
socket.setNoDelay(true);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Send the SSE response headers IMMEDIATELY (before the protocol probe): the
|
|
856
|
+
// probe below can block for the upstream's first byte (slow thinking), and
|
|
857
|
+
// without an early response the client sees nothing and times out on its own,
|
|
858
|
+
// retrying the same session. The honoStream callback emits keep-alive
|
|
859
|
+
// comments while the first byte is pending.
|
|
860
|
+
c.header("Content-Type", "text/event-stream");
|
|
861
|
+
c.header("Cache-Control", "no-cache, no-transform");
|
|
862
|
+
c.header("Connection", "keep-alive");
|
|
863
|
+
c.header("X-Accel-Buffering", "no");
|
|
864
|
+
// Store retry context for transparent mid-stream recovery
|
|
865
|
+
const retryContext = {
|
|
866
|
+
activeAccountId,
|
|
867
|
+
activeAccountLabel: activeAccountLabel || activeAccountId,
|
|
868
|
+
uiSessionId,
|
|
869
|
+
retriesLeft: Math.max(0, config.retry.maxAttempts - 1),
|
|
870
|
+
releaseAccountLease: midStreamRetry?.releaseAccountLease ?? null,
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
return honoStream(c, async (streamWriter: any) => {
|
|
874
|
+
let heartbeatTimeout: NodeJS.Timeout | undefined;
|
|
875
|
+
let clientDisconnected = false;
|
|
876
|
+
let gracePending = false;
|
|
877
|
+
let graceTimer: NodeJS.Timeout | null = null;
|
|
878
|
+
let teardownDone = false;
|
|
879
|
+
let recoverySeedPending = false;
|
|
880
|
+
let currentUiSessionId = retryContext.uiSessionId;
|
|
881
|
+
let currentAccountId = retryContext.activeAccountId;
|
|
882
|
+
let activeReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
883
|
+
let invalidInputSameAccountRetries = 0;
|
|
884
|
+
let chatInProgressSameAccountRetries = 0;
|
|
885
|
+
// Set when the terminal [DONE] reached the client. The `| recovered`
|
|
886
|
+
// suffix on Stream done must only appear for attempts that COMPLETED after
|
|
887
|
+
// a mid-stream retry, not for failed attempts that consumed retries.
|
|
888
|
+
let streamCompletedOk = false;
|
|
889
|
+
// Set when the turn is closed early because the per-turn tool-call cap was
|
|
890
|
+
// reached. The turn ends CLEANLY (finish_reason "tool_calls" + [DONE]) and
|
|
891
|
+
// the upstream generation is stopped — this is a success path, never a
|
|
892
|
+
// mid-stream retry. The next turn carries a notice so the model knows calls
|
|
893
|
+
// beyond the cap were not executed.
|
|
894
|
+
let stoppedByToolCap = false;
|
|
895
|
+
|
|
896
|
+
// The client socket went away. When config.stream.disconnectGraceMs > 0 we
|
|
897
|
+
// do NOT tear down Qwen/stop/release the lease immediately: a transient
|
|
898
|
+
// network blip (2-4s) would otherwise kill the in-flight generation, mark
|
|
899
|
+
// the account temporarily busy and splinter the thread. Within the window
|
|
900
|
+
// the upstream keeps generating; if it finishes naturally, the parent is
|
|
901
|
+
// still committed and the reconnecting client continues the thread
|
|
902
|
+
// cleanly. A still-running generation gets the teardown after the window.
|
|
903
|
+
const runDisconnectTeardown = () => {
|
|
904
|
+
if (teardownDone) return;
|
|
905
|
+
teardownDone = true;
|
|
906
|
+
|
|
907
|
+
if (logger.isLevelEnabled("info")) {
|
|
908
|
+
console.log(
|
|
909
|
+
`🔌 [Chat] Client disconnected | ${completionId} | stopping Qwen generation`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (isToolcallDebugEnabled()) {
|
|
914
|
+
logger.debug("[chat] stream: client disconnected", {
|
|
915
|
+
completionId,
|
|
916
|
+
uiSessionId: currentUiSessionId,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const streamData = getStream(completionId);
|
|
921
|
+
const targetResponseId = streamData?.targetResponseId || "";
|
|
922
|
+
const stopSessionId = currentUiSessionId;
|
|
923
|
+
const stopAccountId = currentAccountId;
|
|
924
|
+
const stopHeaders = streamData?.headers;
|
|
925
|
+
|
|
926
|
+
// Qwen may keep the generation alive briefly after the browser stream is
|
|
927
|
+
// aborted. Mark the account busy during that settlement window so the next
|
|
928
|
+
// request does not race the stop endpoint and receive chat_in_progress.
|
|
929
|
+
const stopSettlementMs = Math.max(
|
|
930
|
+
config.retry.chatInProgressBusyMs,
|
|
931
|
+
1_000,
|
|
932
|
+
);
|
|
933
|
+
const stopBusyUntil = markAccountTemporarilyBusy(
|
|
934
|
+
stopAccountId,
|
|
935
|
+
stopSettlementMs,
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
// Abort the browser stream first. This wakes the reader immediately and
|
|
939
|
+
// lets the normal finally block release the account/stream locks without
|
|
940
|
+
// waiting for Qwen's stop endpoint.
|
|
941
|
+
try {
|
|
942
|
+
streamData?.abortController.abort();
|
|
943
|
+
} catch (abortErr: any) {
|
|
944
|
+
if (abortErr.name !== "AbortError") {
|
|
945
|
+
console.error(`❌ [Chat] Abort stream failed | ${abortErr.message}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
void activeReader?.cancel().catch(() => undefined);
|
|
949
|
+
|
|
950
|
+
// Release the lease immediately. The stop request below is best-effort
|
|
951
|
+
// and must never hold the account slot or block the next tool turn.
|
|
952
|
+
retryContext.releaseAccountLease?.();
|
|
953
|
+
retryContext.releaseAccountLease = null;
|
|
954
|
+
removeStream(completionId);
|
|
955
|
+
|
|
956
|
+
if (stopHeaders && stopSessionId && targetResponseId) {
|
|
957
|
+
if (logger.isLevelEnabled("info")) {
|
|
958
|
+
console.log(
|
|
959
|
+
`🛑 [Chat] Stopping Qwen generation | session=${stopSessionId} | response=${targetResponseId}`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
void requestQwenTextInBrowser(
|
|
963
|
+
stopAccountId,
|
|
964
|
+
"POST",
|
|
965
|
+
`/api/v2/chat/completions/stop?chat_id=${encodeURIComponent(stopSessionId)}`,
|
|
966
|
+
buildQwenRequestHeaders({
|
|
967
|
+
cookie: stopHeaders.cookie,
|
|
968
|
+
userAgent: stopHeaders["user-agent"],
|
|
969
|
+
bxUa: stopHeaders["bx-ua"],
|
|
970
|
+
bxUmidtoken: stopHeaders["bx-umidtoken"],
|
|
971
|
+
bxV: stopHeaders["bx-v"],
|
|
972
|
+
chatSessionId: stopSessionId,
|
|
973
|
+
}),
|
|
974
|
+
JSON.stringify({
|
|
975
|
+
chat_id: stopSessionId,
|
|
976
|
+
response_id: targetResponseId,
|
|
977
|
+
}),
|
|
978
|
+
{
|
|
979
|
+
referrer: qwenUrl(`/c/${encodeURIComponent(stopSessionId)}`),
|
|
980
|
+
// The client is already gone and a retry may own the page mutex.
|
|
981
|
+
// A mutex timeout here must NOT trigger the stuck-mutex recovery
|
|
982
|
+
// (close context + reset profile) or a best-effort stop cools the
|
|
983
|
+
// account for 300s.
|
|
984
|
+
noMutexRecovery: true,
|
|
985
|
+
},
|
|
986
|
+
)
|
|
987
|
+
.then(() => {
|
|
988
|
+
// A successful stop response means the account no longer needs the
|
|
989
|
+
// protective busy window.
|
|
990
|
+
clearTemporaryBusy(stopAccountId, stopBusyUntil);
|
|
991
|
+
})
|
|
992
|
+
.catch((err) => {
|
|
993
|
+
console.error(`❌ [Chat] Stop failed | ${err.message}`);
|
|
994
|
+
});
|
|
995
|
+
} else {
|
|
996
|
+
if (logger.isLevelEnabled("info")) {
|
|
997
|
+
console.log(
|
|
998
|
+
`⏭️ [Chat] Skip Qwen stop | ${completionId} | no response_id yet`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (heartbeatTimeout) {
|
|
1004
|
+
clearTimeout(heartbeatTimeout);
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
const abortHandler = () => {
|
|
1009
|
+
if (clientDisconnected) return;
|
|
1010
|
+
clientDisconnected = true;
|
|
1011
|
+
|
|
1012
|
+
const graceMs = config.stream.disconnectGraceMs;
|
|
1013
|
+
if (graceMs > 0) {
|
|
1014
|
+
gracePending = true;
|
|
1015
|
+
if (logger.isLevelEnabled("info")) {
|
|
1016
|
+
console.log(
|
|
1017
|
+
`🔌 [Chat] Client disconnected | ${completionId} | grace ${graceMs}ms - keeping Qwen generation alive`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
graceTimer = setTimeout(() => {
|
|
1021
|
+
gracePending = false;
|
|
1022
|
+
graceTimer = null;
|
|
1023
|
+
runDisconnectTeardown();
|
|
1024
|
+
}, graceMs);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
runDisconnectTeardown();
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
c.req.raw.signal.addEventListener("abort", abortHandler);
|
|
1032
|
+
|
|
1033
|
+
// Micro-buffer: coalesce many tiny SSE writes into fewer socket writes to cut
|
|
1034
|
+
// syscall overhead on long responses. Ordering is preserved because EVERY write
|
|
1035
|
+
// (content, reasoning, events, [DONE]) goes through this single buffer.
|
|
1036
|
+
let writeBuffer = '';
|
|
1037
|
+
let writeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1038
|
+
const WRITE_FLUSH_BYTES = 8192;
|
|
1039
|
+
const WRITE_FLUSH_MS = 3;
|
|
1040
|
+
|
|
1041
|
+
const flushWrites = () => {
|
|
1042
|
+
if (clientDisconnected) {
|
|
1043
|
+
writeBuffer = '';
|
|
1044
|
+
writeTimer = null;
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (writeTimer) { clearTimeout(writeTimer); writeTimer = null; }
|
|
1048
|
+
if (writeBuffer) {
|
|
1049
|
+
const data = writeBuffer;
|
|
1050
|
+
writeBuffer = '';
|
|
1051
|
+
streamWriter.write(data);
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
await streamWriter.write(": heartbeat\n\n");
|
|
1057
|
+
|
|
1058
|
+
// Protocol probe state. The probe itself runs LATER (after the recovery
|
|
1059
|
+
// machinery is defined) so probe failures route through
|
|
1060
|
+
// recoverFromStreamError (mid-stream retry) — the outer retry loop can no
|
|
1061
|
+
// longer run once the response has started. The response is already open
|
|
1062
|
+
// here, so heartbeats cover the wait for the upstream's first byte.
|
|
1063
|
+
const streamReader = stream.getReader();
|
|
1064
|
+
const streamDecoder = new TextDecoder();
|
|
1065
|
+
let initialStreamBuffer = "";
|
|
1066
|
+
let initialProbeBytes = 0;
|
|
1067
|
+
|
|
1068
|
+
const scheduleHeartbeat = () => {
|
|
1069
|
+
heartbeatTimeout = setTimeout(async () => {
|
|
1070
|
+
if (clientDisconnected) return;
|
|
1071
|
+
try {
|
|
1072
|
+
await streamWriter.write(": keep-alive\n\n");
|
|
1073
|
+
scheduleHeartbeat();
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
logger.debug("[streaming] Heartbeat error", {
|
|
1076
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}, 15000);
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
scheduleHeartbeat();
|
|
1083
|
+
|
|
1084
|
+
const createdTimestamp = Math.floor(Date.now() / 1000);
|
|
1085
|
+
|
|
1086
|
+
const bufferedWrite = (data: string) => {
|
|
1087
|
+
if (clientDisconnected) return;
|
|
1088
|
+
writeBuffer += data;
|
|
1089
|
+
if (writeBuffer.length >= WRITE_FLUSH_BYTES) {
|
|
1090
|
+
flushWrites();
|
|
1091
|
+
} else if (!writeTimer) {
|
|
1092
|
+
writeTimer = setTimeout(flushWrites, WRITE_FLUSH_MS);
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
// Batch buffer: when non-null, writeEvent accumulates instead of flushing
|
|
1097
|
+
let flushBuffer: string[] | null = null;
|
|
1098
|
+
|
|
1099
|
+
// Synchronous: avoids per-call Promise allocation on the hot path.
|
|
1100
|
+
const writeEvent = (data: any) => {
|
|
1101
|
+
const serialized = `data: ${JSON.stringify(data)}\n\n`;
|
|
1102
|
+
if (Array.isArray(flushBuffer)) {
|
|
1103
|
+
flushBuffer.push(serialized);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
bufferedWrite(serialized);
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
const makeChoice = (delta: any, finishReason: string | null = null) => ({
|
|
1110
|
+
index: 0,
|
|
1111
|
+
delta,
|
|
1112
|
+
logprobs: null,
|
|
1113
|
+
finish_reason: finishReason,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
// Pre-computed SSE event head for the hot-path text/reasoning deltas.
|
|
1117
|
+
// Byte-identical to JSON.stringify of the equivalent object (verified).
|
|
1118
|
+
const eventHead =
|
|
1119
|
+
`"id":${JSON.stringify(completionId)},"object":"chat.completion.chunk"` +
|
|
1120
|
+
`,"created":${createdTimestamp},"model":${JSON.stringify(body.model)}` +
|
|
1121
|
+
`,"choices":[{"index":0,"delta":`;
|
|
1122
|
+
const eventTail = `,"logprobs":null,"finish_reason":null}]}`;
|
|
1123
|
+
|
|
1124
|
+
const writeDeltaEvent = (delta: Record<string, unknown>) => {
|
|
1125
|
+
// First model output to reach the client: the emit-aware supersede uses
|
|
1126
|
+
// this to allow latest-wins only AFTER the client consumed something.
|
|
1127
|
+
markStreamEmitted(completionId);
|
|
1128
|
+
const now = Date.now();
|
|
1129
|
+
if (firstChunkAt === null) {
|
|
1130
|
+
firstChunkAt = now;
|
|
1131
|
+
if (logger.isLevelEnabled("info")) {
|
|
1132
|
+
console.log(
|
|
1133
|
+
`⏱️ [Chat] First chunk | req=${reqId} | +${firstChunkAt - streamStartedAt}ms`,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
lastDeltaAt = now;
|
|
1138
|
+
const serialized =
|
|
1139
|
+
`data: {${eventHead}${JSON.stringify(delta)}${eventTail}\n\n`;
|
|
1140
|
+
if (Array.isArray(flushBuffer)) {
|
|
1141
|
+
flushBuffer.push(serialized);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
bufferedWrite(serialized);
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
// Initial role chunk. Flush immediately so the first data event is not
|
|
1148
|
+
// held back by the 3 ms coalescing timer (time-to-first-data).
|
|
1149
|
+
writeEvent({
|
|
1150
|
+
id: completionId,
|
|
1151
|
+
object: "chat.completion.chunk",
|
|
1152
|
+
created: createdTimestamp,
|
|
1153
|
+
model: body.model,
|
|
1154
|
+
choices: [makeChoice({ role: "assistant", content: "" })],
|
|
1155
|
+
});
|
|
1156
|
+
flushWrites();
|
|
1157
|
+
|
|
1158
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> = streamReader;
|
|
1159
|
+
activeReader = reader;
|
|
1160
|
+
const decoder = new TextDecoder();
|
|
1161
|
+
|
|
1162
|
+
let lastThinkingSummary = "";
|
|
1163
|
+
let lastThinkingSummaryLength = 0;
|
|
1164
|
+
let lastThinkingSummarySuffix = "";
|
|
1165
|
+
let lastRawContent = "";
|
|
1166
|
+
let lastRawContentLength = 0;
|
|
1167
|
+
let lastRawContentSuffix = "";
|
|
1168
|
+
let finalContent = "";
|
|
1169
|
+
let reasoningBuffer = "";
|
|
1170
|
+
let emittedModelOutput = false;
|
|
1171
|
+
let targetResponseId: string | null = null;
|
|
1172
|
+
let toolParser = shouldParseToolCalls
|
|
1173
|
+
? new StreamingToolParser(declaredTools, {
|
|
1174
|
+
incrementalToolCalls: true,
|
|
1175
|
+
maxToolCallsPerTurn: config.retry.maxToolCallsPerTurn,
|
|
1176
|
+
})
|
|
1177
|
+
: null;
|
|
1178
|
+
|
|
1179
|
+
let buffer = initialStreamBuffer;
|
|
1180
|
+
const usageAccumulator = createUsageAccumulator(0);
|
|
1181
|
+
let pendingParentId: string | null = null;
|
|
1182
|
+
let upstreamDone = false;
|
|
1183
|
+
const rememberSession = (sessionId: string | null) => {
|
|
1184
|
+
if (!sessionId || sessionId === currentUiSessionId) return;
|
|
1185
|
+
currentUiSessionId = sessionId;
|
|
1186
|
+
updateStreamSessionId(completionId, sessionId);
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
const rememberParent = (parentId: string) => {
|
|
1190
|
+
if (!currentUiSessionId) return;
|
|
1191
|
+
updateSessionParent(currentUiSessionId, parentId, currentAccountId);
|
|
1192
|
+
updateLogicalThreadParent(
|
|
1193
|
+
logicalSessionId,
|
|
1194
|
+
parentId,
|
|
1195
|
+
currentAccountId,
|
|
1196
|
+
currentUiSessionId,
|
|
1197
|
+
);
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const emitAnswerText = async (textChunk: string) => {
|
|
1201
|
+
if (textChunk) emittedModelOutput = true;
|
|
1202
|
+
if (!toolParser) {
|
|
1203
|
+
finalContent += textChunk;
|
|
1204
|
+
writeDeltaEvent({ content: textChunk });
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const { text, toolCalls, toolCallDeltas } = toolParser.feed(textChunk);
|
|
1209
|
+
if (text || toolCalls.length > 0 || toolCallDeltas.length > 0) {
|
|
1210
|
+
emittedModelOutput = true;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (
|
|
1214
|
+
isToolcallDebugEnabled() &&
|
|
1215
|
+
(text || toolCalls.length > 0 || toolCallDeltas.length > 0)
|
|
1216
|
+
) {
|
|
1217
|
+
logger.debug("[chat] stream: parser feed result", {
|
|
1218
|
+
textLength: text.length,
|
|
1219
|
+
textPreview: text.substring(0, 100),
|
|
1220
|
+
toolCallsCount: toolCalls.length,
|
|
1221
|
+
toolCallNames: toolCalls.map((tc) => tc.name),
|
|
1222
|
+
toolCallDeltaCount: toolCallDeltas.length,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (text) {
|
|
1227
|
+
finalContent += text;
|
|
1228
|
+
writeDeltaEvent({ content: text });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
for (const delta of toolCallDeltas) {
|
|
1232
|
+
if (isToolcallDebugEnabled()) {
|
|
1233
|
+
logger.debug(
|
|
1234
|
+
"[chat] stream: emitting incremental tool_call delta",
|
|
1235
|
+
{
|
|
1236
|
+
index: delta.index,
|
|
1237
|
+
id: delta.id,
|
|
1238
|
+
name: delta.function.name,
|
|
1239
|
+
argumentsChunkLength: delta.function.arguments?.length || 0,
|
|
1240
|
+
},
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
await writeEvent({
|
|
1245
|
+
id: completionId,
|
|
1246
|
+
object: "chat.completion.chunk",
|
|
1247
|
+
created: createdTimestamp,
|
|
1248
|
+
model: body.model,
|
|
1249
|
+
choices: [
|
|
1250
|
+
makeChoice({
|
|
1251
|
+
tool_calls: [
|
|
1252
|
+
{
|
|
1253
|
+
index: delta.index,
|
|
1254
|
+
...(delta.id ? { id: delta.id } : {}),
|
|
1255
|
+
...(delta.type ? { type: delta.type } : {}),
|
|
1256
|
+
function: {
|
|
1257
|
+
...(delta.function.name
|
|
1258
|
+
? { name: delta.function.name }
|
|
1259
|
+
: {}),
|
|
1260
|
+
...(delta.function.arguments !== undefined
|
|
1261
|
+
? { arguments: delta.function.arguments }
|
|
1262
|
+
: {}),
|
|
1263
|
+
},
|
|
1264
|
+
},
|
|
1265
|
+
],
|
|
1266
|
+
}),
|
|
1267
|
+
],
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
for (const tc of toolCalls) {
|
|
1272
|
+
if (isToolcallDebugEnabled()) {
|
|
1273
|
+
logger.debug("[chat] stream: emitting tool_call chunk", {
|
|
1274
|
+
id: tc.id,
|
|
1275
|
+
name: tc.name,
|
|
1276
|
+
argsKeys: Object.keys(tc.arguments),
|
|
1277
|
+
index:
|
|
1278
|
+
toolParser.getEmittedToolCallCount() -
|
|
1279
|
+
toolCalls.length +
|
|
1280
|
+
toolCalls.indexOf(tc),
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
await writeEvent({
|
|
1285
|
+
id: completionId,
|
|
1286
|
+
object: "chat.completion.chunk",
|
|
1287
|
+
created: createdTimestamp,
|
|
1288
|
+
model: body.model,
|
|
1289
|
+
choices: [
|
|
1290
|
+
makeChoice({
|
|
1291
|
+
tool_calls: [
|
|
1292
|
+
{
|
|
1293
|
+
index:
|
|
1294
|
+
toolParser.getEmittedToolCallCount() -
|
|
1295
|
+
toolCalls.length +
|
|
1296
|
+
toolCalls.indexOf(tc),
|
|
1297
|
+
id: tc.id,
|
|
1298
|
+
type: "function",
|
|
1299
|
+
function: {
|
|
1300
|
+
name: tc.name,
|
|
1301
|
+
arguments: JSON.stringify(tc.arguments),
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1304
|
+
],
|
|
1305
|
+
}),
|
|
1306
|
+
],
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
// After a failover/retry the tool-call parser is fresh but the text
|
|
1312
|
+
// dedup (against the previous attempt's content) may strip the shared
|
|
1313
|
+
// prefix from the first chunk, cutting a tool block mid-way (e.g. the
|
|
1314
|
+
// `<tool_call>{"name":...` header). Feed that deduped-away prefix into
|
|
1315
|
+
// the parser as non-emitted context so the reassembled block completes.
|
|
1316
|
+
const seedParserWithDedupedPrefix = (
|
|
1317
|
+
cumulativeContent: string,
|
|
1318
|
+
emittedDelta: string,
|
|
1319
|
+
): void => {
|
|
1320
|
+
if (!toolParser || !cumulativeContent) return;
|
|
1321
|
+
const newLen = cumulativeContent.length;
|
|
1322
|
+
const emittedLen = emittedDelta.length;
|
|
1323
|
+
if (newLen <= emittedLen) return;
|
|
1324
|
+
const prefix = cumulativeContent.slice(0, newLen - emittedLen);
|
|
1325
|
+
if (!prefix) return;
|
|
1326
|
+
toolParser.feed(prefix);
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
const recoverFromStreamError = async (rawError: unknown): Promise<boolean> => {
|
|
1332
|
+
const normalizedError =
|
|
1333
|
+
rawError instanceof RetryableQwenStreamError
|
|
1334
|
+
? rawError
|
|
1335
|
+
: isNetworkLikeError(rawError)
|
|
1336
|
+
? toRetryableStreamError(
|
|
1337
|
+
"network_error",
|
|
1338
|
+
rawError instanceof Error ? rawError.message : String(rawError),
|
|
1339
|
+
{
|
|
1340
|
+
switchAccount: true,
|
|
1341
|
+
forceNewChat: true,
|
|
1342
|
+
retryAfterMs: 3000,
|
|
1343
|
+
reason: "network",
|
|
1344
|
+
},
|
|
1345
|
+
)
|
|
1346
|
+
: null;
|
|
1347
|
+
|
|
1348
|
+
if (
|
|
1349
|
+
!normalizedError ||
|
|
1350
|
+
(clientDisconnected && !gracePending) ||
|
|
1351
|
+
c.req.raw.signal.aborted ||
|
|
1352
|
+
retryContext.retriesLeft <= 0 ||
|
|
1353
|
+
!midStreamRetry ||
|
|
1354
|
+
emittedModelOutput
|
|
1355
|
+
) {
|
|
1356
|
+
return false;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const policy = classifyRetryAction(normalizedError, {
|
|
1360
|
+
requestAborted: c.req.raw.signal.aborted,
|
|
1361
|
+
});
|
|
1362
|
+
if (!policy.retryable) return false;
|
|
1363
|
+
|
|
1364
|
+
// Full recovery decision — same rationale as the create-path policy
|
|
1365
|
+
// log: the failure line shows the error, this line shows WHY the
|
|
1366
|
+
// chosen action (retry same / switch / new chat / cooldown) was taken.
|
|
1367
|
+
if (logger.isLevelEnabled("info")) {
|
|
1368
|
+
console.log(
|
|
1369
|
+
`🧭 [Chat] Stream recovery policy | account=${currentAccountId} | reason=${policy.reason} | retryable=${policy.retryable} | switch=${policy.switchAccount} | newChat=${policy.forceNewChat} | fullPrompt=${policy.retryWithFullPrompt} | retryAfter=${policy.retryAfterMs}ms`,
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (policy.reason === "corrupted_chat_history") {
|
|
1374
|
+
invalidateLogicalThreadParent(midStreamRetry.sessionId);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
retryContext.retriesLeft--;
|
|
1378
|
+
console.warn(
|
|
1379
|
+
`🔄 [Chat] Stream recovery | account=${currentAccountId} | reason=${policy.reason} | error=${normalizedError.message.substring(0, 150)} | retries_left=${retryContext.retriesLeft}`,
|
|
1380
|
+
);
|
|
1381
|
+
|
|
1382
|
+
const retryInvalidInputOnSameAccount =
|
|
1383
|
+
shouldRetryInvalidInputOnSameAccount(
|
|
1384
|
+
policy.reason,
|
|
1385
|
+
invalidInputSameAccountRetries > 0,
|
|
1386
|
+
);
|
|
1387
|
+
if (retryInvalidInputOnSameAccount) {
|
|
1388
|
+
invalidInputSameAccountRetries++;
|
|
1389
|
+
}
|
|
1390
|
+
const retryChatInProgressOnSameAccount =
|
|
1391
|
+
shouldRetryChatInProgressOnSameAccount(
|
|
1392
|
+
policy.reason,
|
|
1393
|
+
chatInProgressSameAccountRetries,
|
|
1394
|
+
);
|
|
1395
|
+
if (retryChatInProgressOnSameAccount) {
|
|
1396
|
+
chatInProgressSameAccountRetries++;
|
|
1397
|
+
}
|
|
1398
|
+
// chat_in_progress never escalates to an account switch with a
|
|
1399
|
+
// full-context replay (the ~1MB re-upload cost the settle design
|
|
1400
|
+
// removes). After the same-chat settle budget, fail the stream and let
|
|
1401
|
+
// the request-level policy surface the error — the client's own retry
|
|
1402
|
+
// lands on the settled chat, thread intact.
|
|
1403
|
+
if (
|
|
1404
|
+
policy.reason === "chat_in_progress" &&
|
|
1405
|
+
!retryChatInProgressOnSameAccount
|
|
1406
|
+
) {
|
|
1407
|
+
console.warn(
|
|
1408
|
+
`🛑 [Chat] Stream recovery: chat_in_progress budget exhausted | account=${currentAccountId} | failing without full-context replay`,
|
|
1409
|
+
);
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
const switchAccount =
|
|
1413
|
+
policy.switchAccount && !retryInvalidInputOnSameAccount;
|
|
1414
|
+
|
|
1415
|
+
if (
|
|
1416
|
+
switchAccount &&
|
|
1417
|
+
(policy.accountCooldownMs || policy.accountCooldownReason)
|
|
1418
|
+
) {
|
|
1419
|
+
markAccountRateLimited(
|
|
1420
|
+
currentAccountId,
|
|
1421
|
+
policy.accountCooldownMs,
|
|
1422
|
+
policy.accountCooldownReason || "StreamRetry",
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
retryContext.releaseAccountLease?.();
|
|
1427
|
+
retryContext.releaseAccountLease = null;
|
|
1428
|
+
await reader.cancel().catch(() => undefined);
|
|
1429
|
+
removeStream(completionId);
|
|
1430
|
+
|
|
1431
|
+
if (policy.retryAfterMs > 0) {
|
|
1432
|
+
await new Promise((resolve) =>
|
|
1433
|
+
setTimeout(resolve, Math.min(policy.retryAfterMs, 3000)),
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const forceRetryNewChat = policy.forceNewChat;
|
|
1438
|
+
const needsFullPrompt =
|
|
1439
|
+
policy.retryWithFullPrompt || switchAccount || forceRetryNewChat;
|
|
1440
|
+
const newStreamResult = await acquireUpstreamStream({
|
|
1441
|
+
finalPrompt: needsFullPrompt
|
|
1442
|
+
? midStreamRetry.fullPrompt
|
|
1443
|
+
: finalPrompt,
|
|
1444
|
+
fullPrompt: midStreamRetry.fullPrompt,
|
|
1445
|
+
isThinkingModel: midStreamRetry.isThinkingModel,
|
|
1446
|
+
model: body.model,
|
|
1447
|
+
contextModelId: midStreamRetry.contextModelId,
|
|
1448
|
+
shouldResetUpstreamThread: needsFullPrompt,
|
|
1449
|
+
allFiles: policy.dropFiles ? [] : midStreamRetry.allFiles,
|
|
1450
|
+
isNewSession: midStreamRetry.isNewSession,
|
|
1451
|
+
sessionId: midStreamRetry.sessionId,
|
|
1452
|
+
useThreadNative: midStreamRetry.useThreadNative,
|
|
1453
|
+
updateLogicalThread: midStreamRetry.updateLogicalThread,
|
|
1454
|
+
parallelEscape: midStreamRetry.parallelEscape,
|
|
1455
|
+
allowThreadReuse: midStreamRetry.allowThreadReuse,
|
|
1456
|
+
chatMode: midStreamRetry.chatMode,
|
|
1457
|
+
forceNewChat: forceRetryNewChat || switchAccount,
|
|
1458
|
+
preferredAccountId: switchAccount ? null : currentAccountId,
|
|
1459
|
+
excludeAccountIds: switchAccount ? [currentAccountId] : undefined,
|
|
1460
|
+
messageCount: needsFullPrompt
|
|
1461
|
+
? midStreamRetry.fullMessageCount
|
|
1462
|
+
: midStreamRetry.messageCount,
|
|
1463
|
+
fullMessageCount: midStreamRetry.fullMessageCount,
|
|
1464
|
+
toolsCount: midStreamRetry.toolsCount,
|
|
1465
|
+
requestPersonalizationInstruction:
|
|
1466
|
+
midStreamRetry.requestPersonalizationInstruction,
|
|
1467
|
+
contextMode: needsFullPrompt
|
|
1468
|
+
? "replay"
|
|
1469
|
+
: (midStreamRetry.contextMode ?? "delta"),
|
|
1470
|
+
requestSignal: c.req.raw.signal,
|
|
1471
|
+
allowTemporarilyBusyAccountId: currentAccountId,
|
|
1472
|
+
messages: midStreamRetry.messages,
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
if ("error" in newStreamResult) {
|
|
1476
|
+
logger.error("[Chat] Stream recovery could not acquire a new stream", {
|
|
1477
|
+
account: currentAccountId,
|
|
1478
|
+
error: newStreamResult.error?.message || "unknown error",
|
|
1479
|
+
completionId,
|
|
1480
|
+
});
|
|
1481
|
+
throw newStreamResult.error ?? normalizedError;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const previousUiSessionId = currentUiSessionId;
|
|
1485
|
+
currentAccountId = newStreamResult.activeAccountId;
|
|
1486
|
+
currentUiSessionId = newStreamResult.uiSessionId;
|
|
1487
|
+
retryContext.releaseAccountLease =
|
|
1488
|
+
newStreamResult.releaseAccountLease;
|
|
1489
|
+
currentTokenEstimationContext =
|
|
1490
|
+
newStreamResult.tokenEstimationContext;
|
|
1491
|
+
targetResponseId = null;
|
|
1492
|
+
// Keep the text dedup state (lastRawContent/lastThinkingSummary):
|
|
1493
|
+
// the recovery chat re-answers the same question from scratch, so
|
|
1494
|
+
// getIncrementalDelta's common-prefix logic drops the already-emitted
|
|
1495
|
+
// prefix instead of re-printing the first sentence to the client.
|
|
1496
|
+
toolParser = shouldParseToolCalls
|
|
1497
|
+
? new StreamingToolParser(declaredTools, {
|
|
1498
|
+
incrementalToolCalls: true,
|
|
1499
|
+
maxToolCallsPerTurn: config.retry.maxToolCallsPerTurn,
|
|
1500
|
+
})
|
|
1501
|
+
: null;
|
|
1502
|
+
recoverySeedPending = true;
|
|
1503
|
+
Object.assign(usageAccumulator, createUsageAccumulator(0));
|
|
1504
|
+
buffer = "";
|
|
1505
|
+
pendingParentId = null;
|
|
1506
|
+
upstreamDone = false;
|
|
1507
|
+
|
|
1508
|
+
const newEntry = getStream(newStreamResult.completionId);
|
|
1509
|
+
removeStream(newStreamResult.completionId);
|
|
1510
|
+
if (newEntry) {
|
|
1511
|
+
registerStream(completionId, {
|
|
1512
|
+
...newEntry,
|
|
1513
|
+
targetResponseId: "",
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
console.log(
|
|
1518
|
+
`🔄 [Chat] Stream recovery switched account | old=${previousUiSessionId.substring(0, 12)} | new=${currentUiSessionId.substring(0, 12)} | account=${currentAccountId}`,
|
|
1519
|
+
);
|
|
1520
|
+
reader = newStreamResult.stream.getReader();
|
|
1521
|
+
activeReader = reader;
|
|
1522
|
+
return true;
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
// Protocol probe: consume the upstream's first bytes to detect a WAF
|
|
1526
|
+
// HTML / non-SSE / early error payload. Runs here (after
|
|
1527
|
+
// recoverFromStreamError is defined) so probe failures route through the
|
|
1528
|
+
// mid-stream recovery instead of surfacing as an un-retried SSE error.
|
|
1529
|
+
// The response is already open — heartbeats cover the wait for the first
|
|
1530
|
+
// byte (slow thinking).
|
|
1531
|
+
let probeFailed = false;
|
|
1532
|
+
try {
|
|
1533
|
+
while (true) {
|
|
1534
|
+
const { done, value } = await streamReader.read();
|
|
1535
|
+
if (done) {
|
|
1536
|
+
initialStreamBuffer += streamDecoder.decode();
|
|
1537
|
+
break;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
initialStreamBuffer += streamDecoder.decode(value, {
|
|
1541
|
+
stream: true,
|
|
1542
|
+
});
|
|
1543
|
+
if (hasSseProtocolStart(initialStreamBuffer)) {
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
initialProbeBytes += value.byteLength;
|
|
1547
|
+
if (initialProbeBytes > MAX_INITIAL_PROTOCOL_BYTES) {
|
|
1548
|
+
throw toRetryableStreamError(
|
|
1549
|
+
"non_sse_response",
|
|
1550
|
+
"Qwen did not start an SSE response before the protocol probe limit.",
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// NOTE: early SSE error events (data: {"error":...}) are deliberately
|
|
1556
|
+
// NOT checked here — the read loop detects chunk.error and routes it
|
|
1557
|
+
// through recoverFromStreamError. Checking here would duplicate that
|
|
1558
|
+
// and swallow the retry.
|
|
1559
|
+
const probeUpstreamError = parseQwenErrorPayload(initialStreamBuffer);
|
|
1560
|
+
if (probeUpstreamError) {
|
|
1561
|
+
throwParsedUpstreamError(probeUpstreamError);
|
|
1562
|
+
}
|
|
1563
|
+
} catch (probeError) {
|
|
1564
|
+
probeFailed = true;
|
|
1565
|
+
if (await recoverFromStreamError(probeError)) {
|
|
1566
|
+
// Recovery swapped the reader and reset buffer — proceed to the read
|
|
1567
|
+
// loop with the fresh stream.
|
|
1568
|
+
} else {
|
|
1569
|
+
await streamReader.cancel().catch(() => undefined);
|
|
1570
|
+
throw probeError;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (!probeFailed) {
|
|
1574
|
+
buffer = initialStreamBuffer;
|
|
1575
|
+
}
|
|
1576
|
+
activeReader = reader;
|
|
1577
|
+
|
|
1578
|
+
// Main SSE reader loop
|
|
1579
|
+
while (true) {
|
|
1580
|
+
if (clientDisconnected && !gracePending) {
|
|
1581
|
+
if (isToolcallDebugEnabled()) {
|
|
1582
|
+
logger.debug("[chat] stream: breaking loop - client disconnected");
|
|
1583
|
+
}
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Single indexOf scan: also serves as the "need more data" guard,
|
|
1588
|
+
// avoiding a second full-buffer pass via includes().
|
|
1589
|
+
let lineStart = 0;
|
|
1590
|
+
let lineEnd = buffer.indexOf("\n");
|
|
1591
|
+
|
|
1592
|
+
if (lineEnd === -1) {
|
|
1593
|
+
let readResult: ReadableStreamReadResult<Uint8Array>;
|
|
1594
|
+
try {
|
|
1595
|
+
readResult = await reader.read();
|
|
1596
|
+
} catch (readError) {
|
|
1597
|
+
if (await recoverFromStreamError(readError)) continue;
|
|
1598
|
+
throw readError;
|
|
1599
|
+
}
|
|
1600
|
+
if (readResult.done) break;
|
|
1601
|
+
|
|
1602
|
+
buffer += decoder.decode(readResult.value, { stream: true });
|
|
1603
|
+
lineEnd = buffer.indexOf("\n");
|
|
1604
|
+
if (lineEnd === -1) continue;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
for (; lineEnd !== -1; lineEnd = buffer.indexOf("\n", lineStart)) {
|
|
1608
|
+
// Extract the data payload with a single substring (no slice/trim
|
|
1609
|
+
// cascade). Qwen SSE lines are well-formed `data: <json>` + LF/CRLF.
|
|
1610
|
+
let dataStr = "";
|
|
1611
|
+
if (buffer.startsWith("data:", lineStart)) {
|
|
1612
|
+
let s = lineStart + 5;
|
|
1613
|
+
if (buffer.charCodeAt(s) === 32) s++; // single space after "data:"
|
|
1614
|
+
let e = lineEnd;
|
|
1615
|
+
if (e > s && buffer.charCodeAt(e - 1) === 13) e--; // \r of CRLF
|
|
1616
|
+
dataStr = buffer.substring(s, e);
|
|
1617
|
+
}
|
|
1618
|
+
lineStart = lineEnd + 1;
|
|
1619
|
+
if (!dataStr) continue;
|
|
1620
|
+
|
|
1621
|
+
if (dataStr === "[DONE]") {
|
|
1622
|
+
upstreamDone = true;
|
|
1623
|
+
if (!clientDisconnected) {
|
|
1624
|
+
// Drain buffered deltas first; the single final [DONE] is
|
|
1625
|
+
// emitted by the stream tail below so we exit the read loop
|
|
1626
|
+
// immediately without waiting on the keep-alive connection.
|
|
1627
|
+
flushWrites();
|
|
1628
|
+
}
|
|
1629
|
+
break; // Exit the for loop; the while check below leaves the read loop
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
if (upstreamDebugEnabled) {
|
|
1633
|
+
console.log(`📤 [Upstream] Chunk | ${dataStr.substring(0, 500)}`);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Fast-path: simple text delta (avoids JSON.parse for ~90% of chunks)
|
|
1637
|
+
const fastMatch = dataStr.match(
|
|
1638
|
+
/^\{"response_id":"[^"]*","choices":\[\{"delta":\{"content":"((?:[^"\\]|\\.)*)"\}\}\]\}$/,
|
|
1639
|
+
);
|
|
1640
|
+
if (fastMatch) {
|
|
1641
|
+
const unescaped = fastMatch[1]
|
|
1642
|
+
.replace(/\\n/g, "\n")
|
|
1643
|
+
.replace(/\\t/g, "\t")
|
|
1644
|
+
.replace(/\\"/g, '"')
|
|
1645
|
+
.replace(/\\\\/g, "\\");
|
|
1646
|
+
|
|
1647
|
+
if (unescaped) {
|
|
1648
|
+
const result = getIncrementalDelta(
|
|
1649
|
+
lastRawContent,
|
|
1650
|
+
unescaped,
|
|
1651
|
+
lastRawContentLength,
|
|
1652
|
+
lastRawContentSuffix,
|
|
1653
|
+
);
|
|
1654
|
+
const vStr = result.delta;
|
|
1655
|
+
if (recoverySeedPending && unescaped) {
|
|
1656
|
+
seedParserWithDedupedPrefix(unescaped, vStr || "");
|
|
1657
|
+
recoverySeedPending = false;
|
|
1658
|
+
}
|
|
1659
|
+
if (vStr && vStr !== "FINISHED") {
|
|
1660
|
+
lastRawContent = result.matchedContent;
|
|
1661
|
+
lastRawContentLength = result.contentLength;
|
|
1662
|
+
lastRawContentSuffix = result.contentSuffix;
|
|
1663
|
+
await emitAnswerText(vStr);
|
|
1664
|
+
if (toolParser?.isToolCapReached()) {
|
|
1665
|
+
stoppedByToolCap = true;
|
|
1666
|
+
if (!clientDisconnected) flushWrites();
|
|
1667
|
+
break;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
try {
|
|
1675
|
+
const chunk = JSON.parse(dataStr);
|
|
1676
|
+
rememberSession(extractChatSessionId(chunk));
|
|
1677
|
+
|
|
1678
|
+
// Generic upstream SSE error handling (retry/switch via policy)
|
|
1679
|
+
if (chunk.error) {
|
|
1680
|
+
const errDetails =
|
|
1681
|
+
chunk.error.details ||
|
|
1682
|
+
chunk.error.message ||
|
|
1683
|
+
JSON.stringify(chunk.error);
|
|
1684
|
+
const errCode = chunk.error.code || "upstream_error";
|
|
1685
|
+
throwFromSseUpstreamError(errCode, errDetails);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (
|
|
1689
|
+
chunk["response.created"] &&
|
|
1690
|
+
chunk["response.created"].response_id
|
|
1691
|
+
) {
|
|
1692
|
+
// chat_id first so rememberParent can bind sticky state
|
|
1693
|
+
if (chunk["response.created"].chat_id) {
|
|
1694
|
+
rememberSession(chunk["response.created"].chat_id);
|
|
1695
|
+
}
|
|
1696
|
+
if (!targetResponseId) {
|
|
1697
|
+
targetResponseId = chunk["response.created"].response_id;
|
|
1698
|
+
if (targetResponseId) {
|
|
1699
|
+
updateStreamTargetResponseId(completionId, targetResponseId);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
// Commit the parent only after the stream finishes successfully.
|
|
1703
|
+
pendingParentId = chunk["response.created"].response_id;
|
|
1704
|
+
// Qwen-internal metadata event — never forward to the client.
|
|
1705
|
+
continue;
|
|
1706
|
+
} else if (chunk.response_id && !targetResponseId) {
|
|
1707
|
+
targetResponseId = chunk.response_id;
|
|
1708
|
+
if (targetResponseId) {
|
|
1709
|
+
updateStreamTargetResponseId(completionId, targetResponseId);
|
|
1710
|
+
}
|
|
1711
|
+
pendingParentId = chunk.response_id;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
applyUpstreamUsage(usageAccumulator, chunk.usage);
|
|
1715
|
+
|
|
1716
|
+
let vStr = "";
|
|
1717
|
+
let foundStr = false;
|
|
1718
|
+
let isThinkingChunk = false;
|
|
1719
|
+
|
|
1720
|
+
if (
|
|
1721
|
+
chunk.choices &&
|
|
1722
|
+
chunk.choices[0] &&
|
|
1723
|
+
chunk.choices[0].delta &&
|
|
1724
|
+
(targetResponseId === null ||
|
|
1725
|
+
chunk.response_id === targetResponseId)
|
|
1726
|
+
) {
|
|
1727
|
+
const delta = chunk.choices[0].delta;
|
|
1728
|
+
|
|
1729
|
+
// Qwen streams may end with a {"status":"finished",
|
|
1730
|
+
// "phase":"answer"} delta and NO trailing [DONE]. Treat it as
|
|
1731
|
+
// the terminal event so we don't wait on the keep-alive
|
|
1732
|
+
// connection to close (up to the 60s/10min idle timeout).
|
|
1733
|
+
if (delta.phase === "answer" && delta.status === "finished") {
|
|
1734
|
+
upstreamDone = true;
|
|
1735
|
+
if (!clientDisconnected) flushWrites();
|
|
1736
|
+
break; // Exit the for loop; the while check leaves the read loop
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
if (delta.phase === "thinking_summary") {
|
|
1740
|
+
isThinkingChunk = true;
|
|
1741
|
+
const formattedSummary = formatThinkingSummaryContent(delta);
|
|
1742
|
+
if (formattedSummary) {
|
|
1743
|
+
const result = getIncrementalDelta(
|
|
1744
|
+
lastThinkingSummary,
|
|
1745
|
+
formattedSummary,
|
|
1746
|
+
lastThinkingSummaryLength,
|
|
1747
|
+
lastThinkingSummarySuffix,
|
|
1748
|
+
);
|
|
1749
|
+
vStr = result.delta;
|
|
1750
|
+
lastThinkingSummary = result.matchedContent;
|
|
1751
|
+
lastThinkingSummaryLength = result.contentLength;
|
|
1752
|
+
lastThinkingSummarySuffix = result.contentSuffix;
|
|
1753
|
+
if (vStr) {
|
|
1754
|
+
foundStr = true;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
} else if (delta.phase === "answer") {
|
|
1758
|
+
isThinkingChunk = false;
|
|
1759
|
+
if (delta.content !== undefined) {
|
|
1760
|
+
const newContent = delta.content || "";
|
|
1761
|
+
const result = getIncrementalDelta(
|
|
1762
|
+
lastRawContent,
|
|
1763
|
+
newContent,
|
|
1764
|
+
lastRawContentLength,
|
|
1765
|
+
lastRawContentSuffix,
|
|
1766
|
+
);
|
|
1767
|
+
vStr = result.delta;
|
|
1768
|
+
if (recoverySeedPending && newContent) {
|
|
1769
|
+
seedParserWithDedupedPrefix(newContent, vStr || "");
|
|
1770
|
+
recoverySeedPending = false;
|
|
1771
|
+
}
|
|
1772
|
+
if (vStr) {
|
|
1773
|
+
lastRawContent = result.matchedContent;
|
|
1774
|
+
lastRawContentLength = result.contentLength;
|
|
1775
|
+
lastRawContentSuffix = result.contentSuffix;
|
|
1776
|
+
foundStr = true;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
if (foundStr && vStr !== "") {
|
|
1783
|
+
if (vStr === "FINISHED") continue;
|
|
1784
|
+
|
|
1785
|
+
if (isThinkingChunk) {
|
|
1786
|
+
emittedModelOutput = true;
|
|
1787
|
+
reasoningBuffer += vStr;
|
|
1788
|
+
writeDeltaEvent({ reasoning_content: vStr });
|
|
1789
|
+
} else {
|
|
1790
|
+
await emitAnswerText(vStr);
|
|
1791
|
+
if (toolParser?.isToolCapReached()) {
|
|
1792
|
+
stoppedByToolCap = true;
|
|
1793
|
+
if (!clientDisconnected) flushWrites();
|
|
1794
|
+
break;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
} catch (_e) {
|
|
1799
|
+
// Never start a transparent retry after the downstream client has
|
|
1800
|
+
// gone away. A stop/abort can race the upstream error and otherwise
|
|
1801
|
+
// keep creating requests on the same sticky account in the
|
|
1802
|
+
// background.
|
|
1803
|
+
if ((clientDisconnected && !gracePending) || c.req.raw.signal.aborted) {
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
if (await recoverFromStreamError(_e)) {
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
if (_e instanceof RetryableQwenStreamError) {
|
|
1812
|
+
throw _e;
|
|
1813
|
+
}
|
|
1814
|
+
// Ignore partial chunk parse errors.
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// A terminal [DONE] / answer-finished delta exits via `break` from
|
|
1819
|
+
// the for loop above; leave the while loop instead of re-reading so a
|
|
1820
|
+
// lingering keep-alive upstream connection doesn't stall the tail
|
|
1821
|
+
// (finish_reason + [DONE]) until the idle timeout or connection close.
|
|
1822
|
+
if (upstreamDone) break;
|
|
1823
|
+
if (stoppedByToolCap) break;
|
|
1824
|
+
|
|
1825
|
+
buffer = lineStart > 0 ? buffer.slice(lineStart) : buffer;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Tool-call cap reached: stop the upstream generation now. The turn
|
|
1829
|
+
// closes cleanly below (finish_reason "tool_calls" + [DONE]); this is a
|
|
1830
|
+
// SUCCESS path, never a mid-stream retry. Cancelling the active reader
|
|
1831
|
+
// closes the upstream connection so Qwen stops generating the calls that
|
|
1832
|
+
// would only be dropped.
|
|
1833
|
+
if (stoppedByToolCap) {
|
|
1834
|
+
logger.warn("[chat] stream: tool-call cap reached — closing turn early", {
|
|
1835
|
+
completionId,
|
|
1836
|
+
maxToolCallsPerTurn: config.retry.maxToolCallsPerTurn,
|
|
1837
|
+
emittedToolCalls: toolParser?.getEmittedToolCallCount() ?? 0,
|
|
1838
|
+
});
|
|
1839
|
+
// Tell the NEXT turn of this session that calls beyond the cap were not
|
|
1840
|
+
// executed, so the model can re-issue them.
|
|
1841
|
+
setToolCapNotice(logicalSessionId);
|
|
1842
|
+
await reader.cancel().catch(() => undefined);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// Post-stream: error check + flush remaining content
|
|
1846
|
+
const upstreamError = parseQwenErrorPayload(buffer);
|
|
1847
|
+
if (upstreamError) {
|
|
1848
|
+
await writeEvent({
|
|
1849
|
+
id: completionId,
|
|
1850
|
+
object: "chat.completion.chunk",
|
|
1851
|
+
created: createdTimestamp,
|
|
1852
|
+
model: body.model,
|
|
1853
|
+
choices: [makeChoice({ content: upstreamError.message })],
|
|
1854
|
+
});
|
|
1855
|
+
await writeEvent({
|
|
1856
|
+
id: completionId,
|
|
1857
|
+
object: "chat.completion.chunk",
|
|
1858
|
+
created: createdTimestamp,
|
|
1859
|
+
model: body.model,
|
|
1860
|
+
choices: [makeChoice({}, "stop")],
|
|
1861
|
+
});
|
|
1862
|
+
flushWrites();
|
|
1863
|
+
await streamWriter.write("data: [DONE]\n\n");
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// Activate batch mode — all writeEvent calls accumulate until flushed
|
|
1868
|
+
flushBuffer = [];
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
const remainingParsed = toolParser
|
|
1873
|
+
? toolParser.flush()
|
|
1874
|
+
: { text: "", toolCalls: [], toolCallDeltas: [] };
|
|
1875
|
+
const {
|
|
1876
|
+
text: remainingText,
|
|
1877
|
+
toolCalls: remainingToolCalls,
|
|
1878
|
+
toolCallDeltas: remainingToolCallDeltas,
|
|
1879
|
+
} = remainingParsed;
|
|
1880
|
+
|
|
1881
|
+
if (toolParser && isToolcallDebugEnabled()) {
|
|
1882
|
+
logger.debug("[chat] stream: parser flush result", {
|
|
1883
|
+
remainingTextLength: remainingText?.length || 0,
|
|
1884
|
+
remainingToolCallsCount: remainingToolCalls.length,
|
|
1885
|
+
remainingToolCallNames: remainingToolCalls.map((tc) => tc.name),
|
|
1886
|
+
remainingToolCallDeltaCount: remainingToolCallDeltas.length,
|
|
1887
|
+
totalEmittedToolCalls: toolParser.getEmittedToolCallCount(),
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
if (remainingText) {
|
|
1892
|
+
finalContent += remainingText;
|
|
1893
|
+
await writeEvent({
|
|
1894
|
+
id: completionId,
|
|
1895
|
+
object: "chat.completion.chunk",
|
|
1896
|
+
created: createdTimestamp,
|
|
1897
|
+
model: body.model,
|
|
1898
|
+
choices: [makeChoice({ content: remainingText })],
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
for (const delta of remainingToolCallDeltas) {
|
|
1902
|
+
if (toolParser && isToolcallDebugEnabled()) {
|
|
1903
|
+
logger.debug(
|
|
1904
|
+
"[chat] stream: emitting flushed incremental tool_call delta",
|
|
1905
|
+
{
|
|
1906
|
+
index: delta.index,
|
|
1907
|
+
id: delta.id,
|
|
1908
|
+
name: delta.function.name,
|
|
1909
|
+
argumentsChunkLength: delta.function.arguments?.length || 0,
|
|
1910
|
+
},
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
await writeEvent({
|
|
1915
|
+
id: completionId,
|
|
1916
|
+
object: "chat.completion.chunk",
|
|
1917
|
+
created: createdTimestamp,
|
|
1918
|
+
model: body.model,
|
|
1919
|
+
choices: [
|
|
1920
|
+
makeChoice({
|
|
1921
|
+
tool_calls: [
|
|
1922
|
+
{
|
|
1923
|
+
index: delta.index,
|
|
1924
|
+
...(delta.id ? { id: delta.id } : {}),
|
|
1925
|
+
...(delta.type ? { type: delta.type } : {}),
|
|
1926
|
+
function: {
|
|
1927
|
+
...(delta.function.name
|
|
1928
|
+
? { name: delta.function.name }
|
|
1929
|
+
: {}),
|
|
1930
|
+
...(delta.function.arguments !== undefined
|
|
1931
|
+
? { arguments: delta.function.arguments }
|
|
1932
|
+
: {}),
|
|
1933
|
+
},
|
|
1934
|
+
},
|
|
1935
|
+
],
|
|
1936
|
+
}),
|
|
1937
|
+
],
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
for (const tc of remainingToolCalls) {
|
|
1941
|
+
if (toolParser && isToolcallDebugEnabled()) {
|
|
1942
|
+
logger.debug("[chat] stream: emitting flushed tool_call chunk", {
|
|
1943
|
+
id: tc.id,
|
|
1944
|
+
name: tc.name,
|
|
1945
|
+
argsKeys: Object.keys(tc.arguments),
|
|
1946
|
+
index:
|
|
1947
|
+
toolParser.getEmittedToolCallCount() -
|
|
1948
|
+
remainingToolCalls.length +
|
|
1949
|
+
remainingToolCalls.indexOf(tc),
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
await writeEvent({
|
|
1954
|
+
id: completionId,
|
|
1955
|
+
object: "chat.completion.chunk",
|
|
1956
|
+
created: createdTimestamp,
|
|
1957
|
+
model: body.model,
|
|
1958
|
+
choices: [
|
|
1959
|
+
makeChoice({
|
|
1960
|
+
tool_calls: [
|
|
1961
|
+
{
|
|
1962
|
+
index: toolParser
|
|
1963
|
+
? toolParser.getEmittedToolCallCount() -
|
|
1964
|
+
remainingToolCalls.length +
|
|
1965
|
+
remainingToolCalls.indexOf(tc)
|
|
1966
|
+
: remainingToolCalls.indexOf(tc),
|
|
1967
|
+
id: tc.id,
|
|
1968
|
+
type: "function",
|
|
1969
|
+
function: {
|
|
1970
|
+
name: tc.name,
|
|
1971
|
+
arguments: JSON.stringify(tc.arguments),
|
|
1972
|
+
},
|
|
1973
|
+
},
|
|
1974
|
+
],
|
|
1975
|
+
}),
|
|
1976
|
+
],
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// ── Auto-retry malformed tool calls BEFORE finish reason ────────────
|
|
1981
|
+
// Must run before finish-reason/[DONE] so the client never sees a
|
|
1982
|
+
// premature finish_reason or [DONE] before the retry's chunks.
|
|
1983
|
+
if (
|
|
1984
|
+
!clientDisconnected &&
|
|
1985
|
+
midStreamRetry &&
|
|
1986
|
+
toolParser &&
|
|
1987
|
+
config.retry.autoRetryMalformedTools !== false
|
|
1988
|
+
) {
|
|
1989
|
+
let malformedRetryCount = midStreamRetry.malformedRetryCount ?? 0;
|
|
1990
|
+
const maxMalformedRetries = config.retry.autoRetryMalformedToolsMax;
|
|
1991
|
+
let activeRetryStream: ReadableStream<Uint8Array> | null = null;
|
|
1992
|
+
|
|
1993
|
+
while (malformedRetryCount < maxMalformedRetries) {
|
|
1994
|
+
if (!toolParser) break;
|
|
1995
|
+
const allToolsFailed =
|
|
1996
|
+
toolParser.getMalformedToolCalls().length > 0 &&
|
|
1997
|
+
toolParser.getEmittedToolCallCount() === 0;
|
|
1998
|
+
if (!allToolsFailed) break;
|
|
1999
|
+
|
|
2000
|
+
const malformedCalls = toolParser.getMalformedToolCalls();
|
|
2001
|
+
const malformedCount = malformedCalls.length;
|
|
2002
|
+
|
|
2003
|
+
const undeclaredNames = malformedCalls
|
|
2004
|
+
.flatMap((mc) => mc.undeclaredNames || [])
|
|
2005
|
+
.filter((name, index, self) => self.indexOf(name) === index);
|
|
2006
|
+
|
|
2007
|
+
const availableToolNames = declaredTools
|
|
2008
|
+
.map((t: any) => (t.type === "function" ? t.function?.name : t.name))
|
|
2009
|
+
.filter((n: string | undefined): n is string => !!n);
|
|
2010
|
+
const toolsHint =
|
|
2011
|
+
availableToolNames.length > 0
|
|
2012
|
+
? `\n\nAvailable tools: ${availableToolNames.join(", ")}`
|
|
2013
|
+
: "";
|
|
2014
|
+
|
|
2015
|
+
let errorMessage: string;
|
|
2016
|
+
if (undeclaredNames.length > 0) {
|
|
2017
|
+
errorMessage = `Your previous ${malformedCount} tool call(s) used undeclared tool names: ${undeclaredNames.join(", ")}. Only declared tools can be executed. Please retry with valid tool names.${toolsHint}`;
|
|
2018
|
+
} else {
|
|
2019
|
+
const previews = malformedCalls
|
|
2020
|
+
.slice(0, 3)
|
|
2021
|
+
.map((mc) => mc.contentPreview?.substring(0, 100) || "(empty)")
|
|
2022
|
+
.join("\n - ");
|
|
2023
|
+
errorMessage = `Your previous ${malformedCount} tool call(s) were malformed and could not be executed. The JSON was invalid or truncated. Please retry with valid JSON.\n\nFailed attempt(s):\n - ${previews}${toolsHint}`;
|
|
2024
|
+
}
|
|
2025
|
+
if (isToolcallDebugEnabled()) {
|
|
2026
|
+
logger.debug("[chat] stream: auto-retrying malformed tool calls", {
|
|
2027
|
+
malformedCount,
|
|
2028
|
+
undeclaredNames,
|
|
2029
|
+
completionId,
|
|
2030
|
+
retryAttempt: malformedRetryCount + 1,
|
|
2031
|
+
maxRetries: maxMalformedRetries,
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
const retryPrompt = `${midStreamRetry.fullPrompt}\n\n[SYSTEM CORRECTION]\n${errorMessage}\n\nPlease retry your tool call(s) with correct JSON and valid tool names from the available tools list above.`;
|
|
2035
|
+
|
|
2036
|
+
// Release the current stream (original or previous retry) and lease.
|
|
2037
|
+
const streamToCancel = activeRetryStream ?? stream;
|
|
2038
|
+
try {
|
|
2039
|
+
await streamToCancel.cancel();
|
|
2040
|
+
} catch (cancelErr) {
|
|
2041
|
+
// Ignore cancel errors
|
|
2042
|
+
}
|
|
2043
|
+
midStreamRetry.releaseAccountLease();
|
|
2044
|
+
|
|
2045
|
+
const newStreamResult = await acquireUpstreamStream({
|
|
2046
|
+
finalPrompt: retryPrompt,
|
|
2047
|
+
fullPrompt: retryPrompt,
|
|
2048
|
+
isThinkingModel: midStreamRetry.isThinkingModel,
|
|
2049
|
+
model: body.model,
|
|
2050
|
+
reasoningMode: midStreamRetry.reasoningMode,
|
|
2051
|
+
shouldResetUpstreamThread: true,
|
|
2052
|
+
allFiles: midStreamRetry.allFiles,
|
|
2053
|
+
isNewSession: midStreamRetry.isNewSession,
|
|
2054
|
+
sessionId: midStreamRetry.sessionId,
|
|
2055
|
+
useThreadNative: midStreamRetry.useThreadNative,
|
|
2056
|
+
updateLogicalThread: midStreamRetry.updateLogicalThread,
|
|
2057
|
+
parallelEscape: midStreamRetry.parallelEscape,
|
|
2058
|
+
allowThreadReuse: midStreamRetry.allowThreadReuse,
|
|
2059
|
+
chatMode: midStreamRetry.chatMode,
|
|
2060
|
+
forceNewChat: true,
|
|
2061
|
+
preferredAccountId: midStreamRetry.activeAccountId,
|
|
2062
|
+
excludeAccountIds: undefined,
|
|
2063
|
+
messageCount: midStreamRetry.messageCount,
|
|
2064
|
+
fullMessageCount: midStreamRetry.fullMessageCount,
|
|
2065
|
+
toolsCount: midStreamRetry.toolsCount,
|
|
2066
|
+
requestPersonalizationInstruction:
|
|
2067
|
+
midStreamRetry.requestPersonalizationInstruction,
|
|
2068
|
+
contextMode: "replay",
|
|
2069
|
+
requestSignal: c.req.raw.signal,
|
|
2070
|
+
messages: midStreamRetry.messages,
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
if ("error" in newStreamResult) {
|
|
2074
|
+
// Client abort during the retry acquisition is expected (the client
|
|
2075
|
+
// may have disconnected while the retry stream was being created):
|
|
2076
|
+
// break silently instead of logging an error.
|
|
2077
|
+
if (newStreamResult.error instanceof ClientAbortedError) {
|
|
2078
|
+
logger.debug(
|
|
2079
|
+
"[chat] stream: auto-retry aborted by client (silent)",
|
|
2080
|
+
{
|
|
2081
|
+
completionId,
|
|
2082
|
+
},
|
|
2083
|
+
);
|
|
2084
|
+
break;
|
|
2085
|
+
}
|
|
2086
|
+
logger.error("[chat] stream: auto-retry failed to acquire stream", {
|
|
2087
|
+
error: newStreamResult.error?.message,
|
|
2088
|
+
completionId,
|
|
2089
|
+
});
|
|
2090
|
+
break;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// P1.1: client may have disconnected while acquiring the retry stream.
|
|
2094
|
+
// Release the fresh lease (idempotent) and stop — nobody will read it.
|
|
2095
|
+
if (clientDisconnected || c.req.raw.signal.aborted) {
|
|
2096
|
+
newStreamResult.releaseAccountLease();
|
|
2097
|
+
break;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
console.log(
|
|
2101
|
+
`🔄 [Chat] Auto-retry (${malformedRetryCount + 1}/${maxMalformedRetries}) | ${newStreamResult.activeAccountLabel} | ${body.model} | chat=${newStreamResult.uiSessionId.substring(0, 12)} | reason=malformed_tool_calls`,
|
|
2102
|
+
);
|
|
2103
|
+
|
|
2104
|
+
// Transfer the retry's stream registry entry to the original
|
|
2105
|
+
// completionId so abort/stop target the active upstream stream.
|
|
2106
|
+
const retryEntry = getStream(newStreamResult.completionId);
|
|
2107
|
+
removeStream(newStreamResult.completionId);
|
|
2108
|
+
if (retryEntry) {
|
|
2109
|
+
registerStream(completionId, {
|
|
2110
|
+
...retryEntry,
|
|
2111
|
+
targetResponseId: "",
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// Reset to a fresh tool-call parser so malformed detection re-evaluates
|
|
2116
|
+
// on the retry output. Keep the TEXT dedup state (lastRawContent/
|
|
2117
|
+
// lastThinkingSummary): the retry re-answers the earlier text, and
|
|
2118
|
+
// getIncrementalDelta drops the already-emitted prefix instead of
|
|
2119
|
+
// re-printing it to the client.
|
|
2120
|
+
toolParser = shouldParseToolCalls
|
|
2121
|
+
? new StreamingToolParser(declaredTools, {
|
|
2122
|
+
incrementalToolCalls: true,
|
|
2123
|
+
maxToolCallsPerTurn: config.retry.maxToolCallsPerTurn,
|
|
2124
|
+
})
|
|
2125
|
+
: null;
|
|
2126
|
+
targetResponseId = null;
|
|
2127
|
+
pendingParentId = null;
|
|
2128
|
+
upstreamDone = false;
|
|
2129
|
+
let retryErrorPayload: unknown = null;
|
|
2130
|
+
let retrySeedPending = true;
|
|
2131
|
+
|
|
2132
|
+
const retryReader = newStreamResult.stream.getReader();
|
|
2133
|
+
activeReader = retryReader;
|
|
2134
|
+
activeRetryStream = newStreamResult.stream;
|
|
2135
|
+
retryContext.releaseAccountLease = newStreamResult.releaseAccountLease;
|
|
2136
|
+
currentUiSessionId = newStreamResult.uiSessionId;
|
|
2137
|
+
currentAccountId = newStreamResult.activeAccountId;
|
|
2138
|
+
const retryDecoder = new TextDecoder();
|
|
2139
|
+
let retryBuf = "";
|
|
2140
|
+
|
|
2141
|
+
retryReadLoop: while (true) {
|
|
2142
|
+
const { done, value } = await retryReader.read();
|
|
2143
|
+
if (done) break;
|
|
2144
|
+
|
|
2145
|
+
retryBuf += retryDecoder.decode(value, { stream: true });
|
|
2146
|
+
let lineStart = 0;
|
|
2147
|
+
let lineEnd = retryBuf.indexOf("\n", lineStart);
|
|
2148
|
+
|
|
2149
|
+
for (; lineEnd !== -1; lineEnd = retryBuf.indexOf("\n", lineStart)) {
|
|
2150
|
+
let dataStr = "";
|
|
2151
|
+
if (retryBuf.startsWith("data:", lineStart)) {
|
|
2152
|
+
let s = lineStart + 5;
|
|
2153
|
+
if (retryBuf.charCodeAt(s) === 32) s++;
|
|
2154
|
+
let e = lineEnd;
|
|
2155
|
+
if (e > s && retryBuf.charCodeAt(e - 1) === 13) e--;
|
|
2156
|
+
dataStr = retryBuf.substring(s, e);
|
|
2157
|
+
}
|
|
2158
|
+
lineStart = lineEnd + 1;
|
|
2159
|
+
if (!dataStr) continue;
|
|
2160
|
+
// Critical detail 2: skip upstream [DONE] so a duplicate [DONE]
|
|
2161
|
+
// does not leak to the client. The single final [DONE] is emitted
|
|
2162
|
+
// after all retries complete.
|
|
2163
|
+
if (dataStr === "[DONE]") {
|
|
2164
|
+
upstreamDone = true;
|
|
2165
|
+
break retryReadLoop;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// Parse the retry stream exactly like the main stream: metadata
|
|
2169
|
+
// events are consumed (never forwarded), cumulative deltas are
|
|
2170
|
+
// deduped, and text/tool deltas stream through emitAnswerText.
|
|
2171
|
+
let parsedChunk: any;
|
|
2172
|
+
try {
|
|
2173
|
+
parsedChunk = JSON.parse(dataStr);
|
|
2174
|
+
} catch {
|
|
2175
|
+
// Incomplete/partial chunk — drop it instead of leaking raw JSON.
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
if (parsedChunk.error) {
|
|
2180
|
+
// Upstream error on the retry channel. Abort retries and let the
|
|
2181
|
+
// normal streaming error path surface it to the client.
|
|
2182
|
+
retryErrorPayload = parsedChunk.error;
|
|
2183
|
+
break retryReadLoop;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
if (parsedChunk["response.created"]) {
|
|
2187
|
+
if (parsedChunk["response.created"].chat_id) {
|
|
2188
|
+
rememberSession(parsedChunk["response.created"].chat_id);
|
|
2189
|
+
}
|
|
2190
|
+
if (!targetResponseId) {
|
|
2191
|
+
targetResponseId = parsedChunk["response.created"].response_id;
|
|
2192
|
+
if (targetResponseId) {
|
|
2193
|
+
updateStreamTargetResponseId(completionId, targetResponseId);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
pendingParentId = parsedChunk["response.created"].response_id;
|
|
2197
|
+
// Qwen-internal metadata event — never forward to the client.
|
|
2198
|
+
continue;
|
|
2199
|
+
} else if (parsedChunk.response_id && !targetResponseId) {
|
|
2200
|
+
rememberSession(extractChatSessionId(parsedChunk));
|
|
2201
|
+
targetResponseId = parsedChunk.response_id;
|
|
2202
|
+
if (targetResponseId) {
|
|
2203
|
+
updateStreamTargetResponseId(completionId, targetResponseId);
|
|
2204
|
+
}
|
|
2205
|
+
pendingParentId = parsedChunk.response_id;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
applyUpstreamUsage(usageAccumulator, parsedChunk.usage);
|
|
2209
|
+
|
|
2210
|
+
const delta = parsedChunk?.choices?.[0]?.delta;
|
|
2211
|
+
if (!delta) {
|
|
2212
|
+
// Non-metadata event without a delta — consume, don't forward.
|
|
2213
|
+
continue;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// The retry stream may also terminate with an answer-finished
|
|
2217
|
+
// delta when upstream sends no [DONE]. Leave the read loop
|
|
2218
|
+
// immediately so we don't stall on the keep-alive connection.
|
|
2219
|
+
if (delta.phase === "answer" && delta.status === "finished") {
|
|
2220
|
+
upstreamDone = true;
|
|
2221
|
+
break retryReadLoop;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
let vStr = "";
|
|
2225
|
+
let foundStr = false;
|
|
2226
|
+
let isThinkingChunk = false;
|
|
2227
|
+
|
|
2228
|
+
if (delta.phase === "thinking_summary") {
|
|
2229
|
+
isThinkingChunk = true;
|
|
2230
|
+
const formattedSummary = formatThinkingSummaryContent(delta);
|
|
2231
|
+
if (formattedSummary) {
|
|
2232
|
+
const result = getIncrementalDelta(
|
|
2233
|
+
lastThinkingSummary,
|
|
2234
|
+
formattedSummary,
|
|
2235
|
+
lastThinkingSummaryLength,
|
|
2236
|
+
lastThinkingSummarySuffix,
|
|
2237
|
+
);
|
|
2238
|
+
vStr = result.delta;
|
|
2239
|
+
lastThinkingSummary = result.matchedContent;
|
|
2240
|
+
lastThinkingSummaryLength = result.contentLength;
|
|
2241
|
+
lastThinkingSummarySuffix = result.contentSuffix;
|
|
2242
|
+
if (vStr) foundStr = true;
|
|
2243
|
+
}
|
|
2244
|
+
} else if (delta.content !== undefined) {
|
|
2245
|
+
const newContent = delta.content || "";
|
|
2246
|
+
const result = getIncrementalDelta(
|
|
2247
|
+
lastRawContent,
|
|
2248
|
+
newContent,
|
|
2249
|
+
lastRawContentLength,
|
|
2250
|
+
lastRawContentSuffix,
|
|
2251
|
+
);
|
|
2252
|
+
vStr = result.delta;
|
|
2253
|
+
if (retrySeedPending && newContent) {
|
|
2254
|
+
seedParserWithDedupedPrefix(newContent, vStr || "");
|
|
2255
|
+
retrySeedPending = false;
|
|
2256
|
+
}
|
|
2257
|
+
if (vStr) {
|
|
2258
|
+
lastRawContent = result.matchedContent;
|
|
2259
|
+
lastRawContentLength = result.contentLength;
|
|
2260
|
+
lastRawContentSuffix = result.contentSuffix;
|
|
2261
|
+
foundStr = true;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
if (foundStr && vStr !== "") {
|
|
2266
|
+
if (vStr === "FINISHED") continue;
|
|
2267
|
+
if (isThinkingChunk) {
|
|
2268
|
+
emittedModelOutput = true;
|
|
2269
|
+
reasoningBuffer += vStr;
|
|
2270
|
+
writeDeltaEvent({ reasoning_content: vStr });
|
|
2271
|
+
} else {
|
|
2272
|
+
await emitAnswerText(vStr);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
retryBuf = lineStart > 0 ? retryBuf.slice(lineStart) : retryBuf;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// Upstream error on the retry channel: surface it and stop retrying.
|
|
2281
|
+
if (retryErrorPayload) {
|
|
2282
|
+
const errSummary =
|
|
2283
|
+
typeof retryErrorPayload === "object" && retryErrorPayload !== null
|
|
2284
|
+
? (retryErrorPayload as any).message ??
|
|
2285
|
+
JSON.stringify(retryErrorPayload).substring(0, 240)
|
|
2286
|
+
: String(retryErrorPayload);
|
|
2287
|
+
throw new Error(
|
|
2288
|
+
`Qwen stream error during malformed-tool retry: ${errSummary}`,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// Flush the retry parser (now the active toolParser) to emit any
|
|
2293
|
+
// remaining buffered content.
|
|
2294
|
+
if (toolParser) {
|
|
2295
|
+
const retryFlush = toolParser.flush();
|
|
2296
|
+
if (retryFlush.text) {
|
|
2297
|
+
finalContent += retryFlush.text;
|
|
2298
|
+
writeDeltaEvent({ content: retryFlush.text });
|
|
2299
|
+
}
|
|
2300
|
+
for (const tcDelta of retryFlush.toolCallDeltas) {
|
|
2301
|
+
writeDeltaEvent({
|
|
2302
|
+
tool_calls: [
|
|
2303
|
+
{
|
|
2304
|
+
index: tcDelta.index,
|
|
2305
|
+
...(tcDelta.id ? { id: tcDelta.id } : {}),
|
|
2306
|
+
...(tcDelta.type ? { type: tcDelta.type } : {}),
|
|
2307
|
+
function: {
|
|
2308
|
+
...(tcDelta.function.name
|
|
2309
|
+
? { name: tcDelta.function.name }
|
|
2310
|
+
: {}),
|
|
2311
|
+
...(tcDelta.function.arguments !== undefined
|
|
2312
|
+
? { arguments: tcDelta.function.arguments }
|
|
2313
|
+
: {}),
|
|
2314
|
+
},
|
|
2315
|
+
},
|
|
2316
|
+
],
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
for (const tc of retryFlush.toolCalls) {
|
|
2320
|
+
writeDeltaEvent({
|
|
2321
|
+
tool_calls: [
|
|
2322
|
+
{
|
|
2323
|
+
index: toolParser.getEmittedToolCallCount() - 1,
|
|
2324
|
+
id: tc.id,
|
|
2325
|
+
type: "function",
|
|
2326
|
+
function: {
|
|
2327
|
+
name: tc.name,
|
|
2328
|
+
arguments: JSON.stringify(tc.arguments),
|
|
2329
|
+
},
|
|
2330
|
+
},
|
|
2331
|
+
],
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// Update state for cleanup.
|
|
2337
|
+
currentTokenEstimationContext = newStreamResult.tokenEstimationContext;
|
|
2338
|
+
// The active toolParser already points at this attempt's parser, so
|
|
2339
|
+
// finish-reason + malformed detection use its results.
|
|
2340
|
+
|
|
2341
|
+
// Propagate retry bookkeeping so a subsequent iteration (or the
|
|
2342
|
+
// non-streaming recursion) sees the updated lease / account / count.
|
|
2343
|
+
midStreamRetry.releaseAccountLease = newStreamResult.releaseAccountLease;
|
|
2344
|
+
midStreamRetry.activeAccountId = newStreamResult.activeAccountId;
|
|
2345
|
+
midStreamRetry.malformedRetryCount = malformedRetryCount + 1;
|
|
2346
|
+
|
|
2347
|
+
malformedRetryCount++;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// The active upstream attempt completed: persist the next-turn parent.
|
|
2352
|
+
// Failed/aborted attempts simply never commit, leaving the last successful
|
|
2353
|
+
// parent as the append point for the next turn.
|
|
2354
|
+
if (pendingParentId && upstreamDone) {
|
|
2355
|
+
rememberParent(pendingParentId);
|
|
2356
|
+
pendingParentId = null;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
// Finish reason + usage + [DONE]
|
|
2360
|
+
const usage = enrichUsageWithContextMeter(
|
|
2361
|
+
buildUsage(usageAccumulator),
|
|
2362
|
+
currentTokenEstimationContext?.contextMeter,
|
|
2363
|
+
);
|
|
2364
|
+
const finalFinishReason =
|
|
2365
|
+
toolParser && toolParser.getEmittedToolCallCount() > 0
|
|
2366
|
+
? "tool_calls"
|
|
2367
|
+
: "stop";
|
|
2368
|
+
|
|
2369
|
+
if (toolParser && isToolcallDebugEnabled()) {
|
|
2370
|
+
logger.debug("[chat] stream: sending finish reason", {
|
|
2371
|
+
finishReason: finalFinishReason,
|
|
2372
|
+
totalEmittedToolCalls: toolParser.getEmittedToolCallCount(),
|
|
2373
|
+
usage,
|
|
2374
|
+
includeUsage: body.stream_options?.include_usage,
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
// Tool calls that were dropped and NOT recovered by the auto-retry. Do NOT
|
|
2379
|
+
// surface a [WARNING] text block to the client: the auto-retry already
|
|
2380
|
+
// sent the correction to Qwen in the upstream prompt, and echoing it here
|
|
2381
|
+
// would leak a bridge-authored text note into the user-facing UI.
|
|
2382
|
+
if (
|
|
2383
|
+
!clientDisconnected &&
|
|
2384
|
+
toolParser &&
|
|
2385
|
+
(toolParser.getMalformedToolCalls().length > 0 ||
|
|
2386
|
+
toolParser.getCappedToolCalls().length > 0)
|
|
2387
|
+
) {
|
|
2388
|
+
const malformedCalls = toolParser.getMalformedToolCalls();
|
|
2389
|
+
const undeclaredNames = malformedCalls
|
|
2390
|
+
.map((mc) => mc.undeclaredNames)
|
|
2391
|
+
.flat()
|
|
2392
|
+
.filter((n): n is string => !!n);
|
|
2393
|
+
const cappedToolNames = toolParser
|
|
2394
|
+
.getCappedToolCalls()
|
|
2395
|
+
.map((c) => c.toolName);
|
|
2396
|
+
|
|
2397
|
+
logger.warn(
|
|
2398
|
+
"[chat] stream: tool calls not retried (malformed or over per-turn cap)",
|
|
2399
|
+
{
|
|
2400
|
+
malformedCount: malformedCalls.length,
|
|
2401
|
+
cappedCount: cappedToolNames.length,
|
|
2402
|
+
cappedToolNames,
|
|
2403
|
+
undeclaredNames,
|
|
2404
|
+
completionId,
|
|
2405
|
+
},
|
|
2406
|
+
);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
await writeEvent({
|
|
2410
|
+
id: completionId,
|
|
2411
|
+
object: "chat.completion.chunk",
|
|
2412
|
+
created: createdTimestamp,
|
|
2413
|
+
model: body.model,
|
|
2414
|
+
choices: [makeChoice({}, finalFinishReason)],
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
if (body.stream_options?.include_usage) {
|
|
2418
|
+
if (isToolcallDebugEnabled()) {
|
|
2419
|
+
logger.debug("[chat] stream: sending usage event", { usage });
|
|
2420
|
+
}
|
|
2421
|
+
await writeEvent({
|
|
2422
|
+
id: completionId,
|
|
2423
|
+
object: "chat.completion.chunk",
|
|
2424
|
+
created: createdTimestamp,
|
|
2425
|
+
model: body.model,
|
|
2426
|
+
choices: [],
|
|
2427
|
+
usage,
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
if (!clientDisconnected) {
|
|
2432
|
+
if (toolParser) {
|
|
2433
|
+
toolParser.clearMalformedToolCalls();
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// Single write: flush all accumulated events + [DONE] sentinel
|
|
2437
|
+
const donePayload = "data: [DONE]\n\n";
|
|
2438
|
+
const payload =
|
|
2439
|
+
flushBuffer && flushBuffer.length > 0
|
|
2440
|
+
? flushBuffer.join("") + donePayload
|
|
2441
|
+
: donePayload;
|
|
2442
|
+
|
|
2443
|
+
if (isToolcallDebugEnabled()) {
|
|
2444
|
+
logger.debug("[chat] stream: sending [DONE]", {
|
|
2445
|
+
batchedEvents: flushBuffer?.length ?? 0,
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
flushWrites();
|
|
2450
|
+
await streamWriter.write(payload);
|
|
2451
|
+
flushBuffer = null;
|
|
2452
|
+
streamCompletedOk = true;
|
|
2453
|
+
|
|
2454
|
+
scheduleAssistantComplete(onAssistantComplete, {
|
|
2455
|
+
sessionId: logicalSessionId,
|
|
2456
|
+
accountId: activeAccountId,
|
|
2457
|
+
chatSessionId: currentUiSessionId,
|
|
2458
|
+
parentId: null,
|
|
2459
|
+
responseId: targetResponseId,
|
|
2460
|
+
userPrompt,
|
|
2461
|
+
finalPrompt,
|
|
2462
|
+
assistantContent: finalContent,
|
|
2463
|
+
reasoningContent: reasoningBuffer || undefined,
|
|
2464
|
+
usage,
|
|
2465
|
+
finishReason: finalFinishReason,
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
if (isToolcallDebugEnabled()) {
|
|
2469
|
+
logger.debug("[chat] stream: completed successfully", {
|
|
2470
|
+
completionId,
|
|
2471
|
+
totalEmittedToolCalls: toolParser
|
|
2472
|
+
? toolParser.getEmittedToolCallCount()
|
|
2473
|
+
: 0,
|
|
2474
|
+
finishReason: finalFinishReason,
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
logTokenEstimationSample({
|
|
2479
|
+
model: body.model,
|
|
2480
|
+
finalPrompt,
|
|
2481
|
+
userPrompt,
|
|
2482
|
+
assistantContent: finalContent,
|
|
2483
|
+
reasoningContent: reasoningBuffer || undefined,
|
|
2484
|
+
usage,
|
|
2485
|
+
mode: "stream",
|
|
2486
|
+
context: currentTokenEstimationContext,
|
|
2487
|
+
});
|
|
2488
|
+
} else {
|
|
2489
|
+
if (isToolcallDebugEnabled()) {
|
|
2490
|
+
logger.debug(
|
|
2491
|
+
"[chat] stream: skipped [DONE] - client already disconnected",
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
} catch (err: any) {
|
|
2496
|
+
const streamStillRegistered = Boolean(getStream(completionId));
|
|
2497
|
+
if (
|
|
2498
|
+
shouldSuppressStreamAbort(
|
|
2499
|
+
err,
|
|
2500
|
+
clientDisconnected,
|
|
2501
|
+
c.req.raw.signal.aborted,
|
|
2502
|
+
streamStillRegistered,
|
|
2503
|
+
)
|
|
2504
|
+
) {
|
|
2505
|
+
if (isToolcallDebugEnabled()) {
|
|
2506
|
+
logger.debug("[chat] stream: suppressed expected abort", {
|
|
2507
|
+
completionId,
|
|
2508
|
+
clientDisconnected,
|
|
2509
|
+
requestAborted: c.req.raw.signal.aborted,
|
|
2510
|
+
streamStillRegistered,
|
|
2511
|
+
errorName: err?.name,
|
|
2512
|
+
errorMessage: err?.message,
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// Idle/upstream aborts are retryable when the client is still connected
|
|
2519
|
+
if (
|
|
2520
|
+
isAbortError(err) &&
|
|
2521
|
+
!clientDisconnected &&
|
|
2522
|
+
!c.req.raw.signal.aborted
|
|
2523
|
+
) {
|
|
2524
|
+
throw toRetryableStreamError(
|
|
2525
|
+
"stream_aborted",
|
|
2526
|
+
err?.message || "This operation was aborted",
|
|
2527
|
+
{
|
|
2528
|
+
switchAccount: true,
|
|
2529
|
+
forceNewChat: true,
|
|
2530
|
+
retryAfterMs: Math.min(config.retry.baseDelayMs * 2, 3000),
|
|
2531
|
+
reason: "stream_aborted",
|
|
2532
|
+
},
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
throw err;
|
|
2536
|
+
} finally {
|
|
2537
|
+
if (isToolcallDebugEnabled()) {
|
|
2538
|
+
logger.debug("[chat] stream: cleanup started", {
|
|
2539
|
+
completionId,
|
|
2540
|
+
clientDisconnected,
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
if (logger.isLevelEnabled("info")) {
|
|
2545
|
+
const initialRetries = Math.max(0, config.retry.maxAttempts - 1);
|
|
2546
|
+
// Only mark as recovered when the stream actually completed (the SSE
|
|
2547
|
+
// terminal event was processed) after a mid-stream retry. A FAILED
|
|
2548
|
+
// attempt that used retries then threw should not say "recovered".
|
|
2549
|
+
const recovered =
|
|
2550
|
+
streamCompletedOk && retryContext.retriesLeft < initialRetries;
|
|
2551
|
+
const tailMs = lastDeltaAt === null ? null : Date.now() - lastDeltaAt;
|
|
2552
|
+
console.log(
|
|
2553
|
+
`⏱️ [Chat] Stream done | req=${reqId} | ${Date.now() - streamStartedAt}ms | firstChunk=${firstChunkAt === null ? "none" : `${firstChunkAt - streamStartedAt}ms`}${tailMs === null ? "" : ` | tail=${tailMs}ms`}${recovered ? ` | recovered` : ""}`,
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
flushWrites();
|
|
2558
|
+
|
|
2559
|
+
// Release the upstream stream lock immediately. The read loop exits on
|
|
2560
|
+
// the SSE terminal event (upstreamDone break) WITHOUT completing the
|
|
2561
|
+
// wrapper's pull(); if the upstream keeps the connection open on
|
|
2562
|
+
// keep-alive after the terminal event, the wrapper never sees done and
|
|
2563
|
+
// the per-account stream lock stays held until the idle timeout (180s for
|
|
2564
|
+
// thinking models) — the next turn on the same account blocks on it until
|
|
2565
|
+
// the acquire deadline fires (the 120s stall). Same pattern as
|
|
2566
|
+
// runDisconnectTeardown.
|
|
2567
|
+
void activeReader?.cancel().catch(() => undefined);
|
|
2568
|
+
|
|
2569
|
+
c.req.raw.signal.removeEventListener("abort", abortHandler);
|
|
2570
|
+
if (heartbeatTimeout) {
|
|
2571
|
+
clearTimeout(heartbeatTimeout);
|
|
2572
|
+
}
|
|
2573
|
+
if (graceTimer) {
|
|
2574
|
+
clearTimeout(graceTimer);
|
|
2575
|
+
graceTimer = null;
|
|
2576
|
+
}
|
|
2577
|
+
removeStream(completionId);
|
|
2578
|
+
|
|
2579
|
+
if (isToolcallDebugEnabled()) {
|
|
2580
|
+
logger.debug("[chat] stream: cleanup completed", {
|
|
2581
|
+
completionId,
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// Release locks now that the stream is fully done
|
|
2586
|
+
if (onStreamComplete) onStreamComplete();
|
|
2587
|
+
|
|
2588
|
+
// Release account lease from transparent retry if active
|
|
2589
|
+
if (retryContext.releaseAccountLease) {
|
|
2590
|
+
retryContext.releaseAccountLease();
|
|
2591
|
+
retryContext.releaseAccountLease = null;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}, async (err: Error, errorStream: any) => {
|
|
2595
|
+
const retryable = err instanceof RetryableQwenStreamError;
|
|
2596
|
+
const errorCode =
|
|
2597
|
+
getQwenErrorCode(err) ||
|
|
2598
|
+
(isNetworkLikeError(err) ? "network_error" : "stream_error");
|
|
2599
|
+
const normalizedErrorCode = errorCode.toLowerCase();
|
|
2600
|
+
const errorType =
|
|
2601
|
+
normalizedErrorCode === "quota_limit" ||
|
|
2602
|
+
normalizedErrorCode === "ratelimited" ||
|
|
2603
|
+
normalizedErrorCode === "rate_limit" ||
|
|
2604
|
+
normalizedErrorCode === "rate_limit_exceeded"
|
|
2605
|
+
? "rate_limit_error"
|
|
2606
|
+
: "upstream_error";
|
|
2607
|
+
|
|
2608
|
+
const errorDetails = {
|
|
2609
|
+
account: activeAccountLabel,
|
|
2610
|
+
accountId: activeAccountId,
|
|
2611
|
+
code: errorCode,
|
|
2612
|
+
errorName: err.name,
|
|
2613
|
+
message: err.message?.substring(0, 200),
|
|
2614
|
+
stack: err.stack?.split("\n").slice(0, 3).join(" | "),
|
|
2615
|
+
completionId,
|
|
2616
|
+
};
|
|
2617
|
+
|
|
2618
|
+
if (retryable) {
|
|
2619
|
+
if (activeAccountId && isNetworkLikeError(err)) {
|
|
2620
|
+
noteMidStreamNetworkFailure(activeAccountId);
|
|
2621
|
+
}
|
|
2622
|
+
logger.warn(
|
|
2623
|
+
"[Chat] Stream ended after retryable error (no more retries)",
|
|
2624
|
+
errorDetails,
|
|
2625
|
+
);
|
|
2626
|
+
} else {
|
|
2627
|
+
logger.error("[Chat] Stream callback error", errorDetails);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// The HTTP response is already committed at this point. Emit a terminal
|
|
2631
|
+
// OpenAI-compatible SSE error instead of silently closing the connection.
|
|
2632
|
+
try {
|
|
2633
|
+
await errorStream.write(
|
|
2634
|
+
`data: ${JSON.stringify({
|
|
2635
|
+
error: {
|
|
2636
|
+
message: err.message,
|
|
2637
|
+
type: errorType,
|
|
2638
|
+
code: errorCode,
|
|
2639
|
+
},
|
|
2640
|
+
})}\n\ndata: [DONE]\n\n`,
|
|
2641
|
+
);
|
|
2642
|
+
} catch (_writeErr) {
|
|
2643
|
+
// Stream already closed — client already disconnected or the stream
|
|
2644
|
+
// was cancelled. Nothing more we can do.
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// ─── Top-level error wrapper ───────────────────────────────────────────────────
|
|
2650
|
+
|
|
2651
|
+
export function handleChatCompletionsError(c: Context, err: unknown): Response {
|
|
2652
|
+
const classified = classifyError(err);
|
|
2653
|
+
|
|
2654
|
+
// Client aborted (or a same-session retry superseded the request) before the
|
|
2655
|
+
// stream could be created. Nobody is listening: do not emit a 500, do not
|
|
2656
|
+
// count it as a request error, and do not log an error line.
|
|
2657
|
+
if (classified instanceof ClientAbortedError) {
|
|
2658
|
+
logger.debug("[chat] client aborted during stream creation (silent)", {
|
|
2659
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2660
|
+
});
|
|
2661
|
+
// 499 (Client Closed Request) is not part of Hono's StatusCode union;
|
|
2662
|
+
// a plain Response (status: number) is valid here.
|
|
2663
|
+
return new Response(null, { status: 499 });
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
if (classified.statusCode >= 500) {
|
|
2667
|
+
metrics.increment("requests.errors");
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2671
|
+
const code = classified.code || "unknown";
|
|
2672
|
+
const status = classified.statusCode;
|
|
2673
|
+
console.error(`❌ [Chat] Error | ${status} ${code} | ${message}`);
|
|
2674
|
+
|
|
2675
|
+
// The one-line error omits WHERE the failure originated (account/chat/reason)
|
|
2676
|
+
// and the stack — both needed to reproduce. Emit the structured detail once;
|
|
2677
|
+
// the classification line stays for the compact terminal.
|
|
2678
|
+
if (logger.isLevelEnabled("info")) {
|
|
2679
|
+
const detail: Record<string, unknown> = {
|
|
2680
|
+
status,
|
|
2681
|
+
code,
|
|
2682
|
+
type: classified.type ?? undefined,
|
|
2683
|
+
message,
|
|
2684
|
+
};
|
|
2685
|
+
if (err instanceof Error) {
|
|
2686
|
+
detail.stack = err.stack;
|
|
2687
|
+
}
|
|
2688
|
+
const quota = (err as any)?.quotaInfo;
|
|
2689
|
+
if (quota) {
|
|
2690
|
+
detail.quota = {
|
|
2691
|
+
email: quota.email,
|
|
2692
|
+
cooldownSeconds: quota.cooldownSeconds,
|
|
2693
|
+
until: quota.untilStr,
|
|
2694
|
+
message: quota.message,
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
const createdChat = (err as any)?.createdNewChat;
|
|
2698
|
+
if (createdChat) {
|
|
2699
|
+
detail.createdNewChat = true;
|
|
2700
|
+
const rawChatId = (err as any)?.chatSessionId;
|
|
2701
|
+
detail.chatId = rawChatId ? String(rawChatId).substring(0, 12) : undefined;
|
|
2702
|
+
detail.accountId = (err as any)?.accountId;
|
|
2703
|
+
}
|
|
2704
|
+
console.log(
|
|
2705
|
+
`🧾 [Chat] Error details | ${JSON.stringify(detail)}`,
|
|
2706
|
+
);
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
return sendOpenAIError(c, err);
|
|
2710
|
+
}
|