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.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. 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
+ }