qwenproxy-cli 1.0.0 → 1.0.2

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