qwenproxy-cli 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -97,6 +97,18 @@ app.post("/v1/messages", async (c) => {
97
97
  try {
98
98
  // 3. Translate Anthropic request to internal OpenAI format
99
99
  const openaiRequest = translateAnthropicToOpenAI(body);
100
+ const claudeSessionId =
101
+ c.req.header("x-claude-code-session-id") ||
102
+ (typeof body.metadata?.user_id === "string" && body.metadata.user_id.includes('"session_id":')
103
+ ? (() => {
104
+ try {
105
+ const parsed = JSON.parse(body.metadata.user_id as string);
106
+ return typeof parsed?.session_id === "string" ? parsed.session_id : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ })()
111
+ : undefined);
100
112
 
101
113
  const dispatchToChat = (streamMode: boolean) =>
102
114
  fetch(`http://127.0.0.1:${config.server.port}/v1/chat/completions`, {
@@ -108,9 +120,11 @@ app.post("/v1/messages", async (c) => {
108
120
  ...(c.req.header("x-qwenproxy-chat-mode")
109
121
  ? { "x-qwenproxy-chat-mode": c.req.header("x-qwenproxy-chat-mode")! }
110
122
  : {}),
123
+ ...(claudeSessionId ? { "x-session-id": claudeSessionId } : {}),
111
124
  },
112
125
  body: JSON.stringify({
113
126
  ...openaiRequest,
127
+ ...(claudeSessionId ? { session_id: claudeSessionId } : {}),
114
128
  stream: streamMode,
115
129
  ...(streamMode ? { stream_options: { include_usage: true } } : {}),
116
130
  }),
@@ -113,6 +113,29 @@ export function formatThinkingSummaryContent(delta: any): string {
113
113
  return sections.join("\n\n");
114
114
  }
115
115
 
116
+ /**
117
+ * Matches thinking phases verified in upstream Qwen Web HAR:
118
+ * - "thinking_summary": structured thinking in Qwen Max / Plus
119
+ * - "think": incremental thinking deltas in Qwen Omni
120
+ */
121
+ export function isThinkingPhase(phase: unknown): boolean {
122
+ return phase === "think" || phase === "thinking_summary";
123
+ }
124
+
125
+ /**
126
+ * Extract thinking/reasoning text from a delta chunk, supporting both
127
+ * structured summaries (summary_title/summary_thought) and direct content deltas.
128
+ */
129
+ export function extractThinkingContent(delta: any): string {
130
+ if (
131
+ Array.isArray(delta?.extra?.summary_title?.content) ||
132
+ Array.isArray(delta?.extra?.summary_thought?.content)
133
+ ) {
134
+ return formatThinkingSummaryContent(delta);
135
+ }
136
+ return typeof delta?.content === "string" ? delta.content : "";
137
+ }
138
+
116
139
  export function isAbortError(err: unknown): boolean {
117
140
  if (err instanceof DOMException) {
118
141
  return err.name === "AbortError";
@@ -67,6 +67,8 @@ import {
67
67
  } from "../../services/context-meter.ts";
68
68
  import {
69
69
  getIncrementalDelta,
70
+ isThinkingPhase,
71
+ extractThinkingContent,
70
72
  formatThinkingSummaryContent,
71
73
  shouldSuppressStreamAbort,
72
74
  isAbortError,
@@ -436,9 +438,9 @@ export async function processNonStreamingResponse(
436
438
  ) {
437
439
  const delta = chunk.choices[0].delta;
438
440
 
439
- if (delta.phase === "thinking_summary") {
441
+ if (isThinkingPhase(delta.phase)) {
440
442
  isThinkingChunk = true;
441
- const formattedSummary = formatThinkingSummaryContent(delta);
443
+ const formattedSummary = extractThinkingContent(delta);
442
444
  if (formattedSummary) {
443
445
  const result = getIncrementalDelta(
444
446
  lastThinkingSummary,
@@ -1737,9 +1739,9 @@ export async function processStreamingResponse(
1737
1739
  break; // Exit the for loop; the while check leaves the read loop
1738
1740
  }
1739
1741
 
1740
- if (delta.phase === "thinking_summary") {
1742
+ if (isThinkingPhase(delta.phase)) {
1741
1743
  isThinkingChunk = true;
1742
- const formattedSummary = formatThinkingSummaryContent(delta);
1744
+ const formattedSummary = extractThinkingContent(delta);
1743
1745
  if (formattedSummary) {
1744
1746
  const result = getIncrementalDelta(
1745
1747
  lastThinkingSummary,
@@ -2254,9 +2256,9 @@ export async function processStreamingResponse(
2254
2256
  let foundStr = false;
2255
2257
  let isThinkingChunk = false;
2256
2258
 
2257
- if (delta.phase === "thinking_summary") {
2259
+ if (isThinkingPhase(delta.phase)) {
2258
2260
  isThinkingChunk = true;
2259
- const formattedSummary = formatThinkingSummaryContent(delta);
2261
+ const formattedSummary = extractThinkingContent(delta);
2260
2262
  if (formattedSummary) {
2261
2263
  const result = getIncrementalDelta(
2262
2264
  lastThinkingSummary,
@@ -2655,10 +2657,29 @@ export async function processStreamingResponse(
2655
2657
  } else {
2656
2658
  logger.error("[Chat] Stream callback error", errorDetails);
2657
2659
  }
2658
-
2659
- // The HTTP response is already committed at this point. Emit a terminal
2660
- // OpenAI-compatible SSE error instead of silently closing the connection.
2660
+ // The HTTP response is already committed at this point.
2661
+ // 1. Emit an explicit assistant message delta so CLI/TUI clients that only
2662
+ // listen for choices[].delta.content (e.g. OpenCode, Claude Code, etc.)
2663
+ // render the error visibly in the chat instead of appearing blank/frozen.
2664
+ // 2. Emit a terminal OpenAI-compatible SSE error event + [DONE].
2661
2665
  try {
2666
+ const userFriendlyNotice = `\n\n⚠️ **[Qwen Security / Erro]** ${err.message || "A resposta foi interrompida pelo Qwen."}\n\n`;
2667
+ const errorDelta = {
2668
+ id: completionId,
2669
+ object: "chat.completion.chunk",
2670
+ created: Math.floor(Date.now() / 1000),
2671
+ model: body.model,
2672
+ choices: [
2673
+ {
2674
+ index: 0,
2675
+ delta: { content: userFriendlyNotice },
2676
+ logprobs: null,
2677
+ finish_reason: "stop",
2678
+ },
2679
+ ],
2680
+ };
2681
+
2682
+ await errorStream.write(`data: ${JSON.stringify(errorDelta)}\n\n`);
2662
2683
  await errorStream.write(
2663
2684
  `data: ${JSON.stringify({
2664
2685
  error: {
@@ -2668,7 +2689,7 @@ export async function processStreamingResponse(
2668
2689
  },
2669
2690
  })}\n\ndata: [DONE]\n\n`,
2670
2691
  );
2671
- } catch (_writeErr) {
2692
+ } catch {
2672
2693
  // Stream already closed — client already disconnected or the stream
2673
2694
  // was cancelled. Nothing more we can do.
2674
2695
  }
@@ -43,14 +43,7 @@ export async function parseRequestBody(c: Context): Promise<ParsedRequest> {
43
43
  const body: OpenAIRequest = await c.req.json();
44
44
  logIncomingChatRequest(c, body);
45
45
  const isStream = body.stream ?? false;
46
- const conversationKey =
47
- typeof body.session_id === "string" && body.session_id.trim().length > 0
48
- ? body.session_id.trim()
49
- : typeof body.conversation_id === "string" &&
50
- body.conversation_id.trim().length > 0
51
- ? body.conversation_id.trim()
52
- : null;
53
-
46
+ const conversationKey = extractExplicitConversationKey(c, body);
54
47
  const messages = body.messages || [];
55
48
  let uploadHeaders: Record<string, string> | null = null;
56
49
 
@@ -328,6 +321,63 @@ function contentLength(value: unknown): number {
328
321
  return 0;
329
322
  }
330
323
 
324
+ export function extractExplicitConversationKey(
325
+ c: Context,
326
+ body: OpenAIRequest,
327
+ ): string | null {
328
+ const b = body as any;
329
+
330
+ // 1. Direct body properties (OpenAI-compatible extensions)
331
+ if (typeof b?.session_id === "string" && b.session_id.trim().length > 0) {
332
+ return b.session_id.trim();
333
+ }
334
+ if (typeof b?.conversation_id === "string" && b.conversation_id.trim().length > 0) {
335
+ return b.conversation_id.trim();
336
+ }
337
+ if (typeof b?.chat_id === "string" && b.chat_id.trim().length > 0) {
338
+ return b.chat_id.trim();
339
+ }
340
+
341
+ // 2. HTTP headers sent natively by coding agents
342
+ const headerCandidates = [
343
+ c.req.header("x-session-id"), // OpenCode, Cline, AI-SDK, custom
344
+ c.req.header("x-session-affinity"), // OpenCode / AI-SDK
345
+ c.req.header("session-id"), // OpenAI Codex CLI
346
+ c.req.header("x-claude-code-session-id"), // Claude Code CLI
347
+ c.req.header("x-conversation-id"), // Standard agents
348
+ c.req.header("conversation-id"), // Standard agents
349
+ c.req.header("x-client-request-id"), // Client fallback ID
350
+ ];
351
+
352
+ for (const candidate of headerCandidates) {
353
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
354
+ return candidate.trim();
355
+ }
356
+ }
357
+
358
+ // 3. Metadata payloads (Claude Code embeds session_id in user_id metadata JSON)
359
+ if (b?.metadata && typeof b.metadata === "object") {
360
+ if (typeof b.metadata.session_id === "string" && b.metadata.session_id.trim().length > 0) {
361
+ return b.metadata.session_id.trim();
362
+ }
363
+ if (typeof b.metadata.user_id === "string" && b.metadata.user_id.includes('"session_id":')) {
364
+ try {
365
+ const parsed = JSON.parse(b.metadata.user_id);
366
+ if (typeof parsed?.session_id === "string" && parsed.session_id.trim().length > 0) {
367
+ return parsed.session_id.trim();
368
+ }
369
+ } catch {}
370
+ }
371
+ }
372
+
373
+ // 4. Prompt cache key (used by Codex for session consistency)
374
+ if (typeof b?.prompt_cache_key === "string" && b.prompt_cache_key.trim().length > 0) {
375
+ return b.prompt_cache_key.trim();
376
+ }
377
+
378
+ return null;
379
+ }
380
+
331
381
  function logIncomingChatRequest(c: Context, body: OpenAIRequest): void {
332
382
  const messages = Array.isArray(body.messages) ? body.messages : [];
333
383
  const tools = Array.isArray((body as any).tools) ? (body as any).tools : [];
@@ -65,11 +65,16 @@ app.post("/v1/responses", async (c) => {
65
65
  historyMessages = history;
66
66
  }
67
67
 
68
+ const responsesSessionId =
69
+ c.req.header("session-id") ||
70
+ c.req.header("x-session-id") ||
71
+ c.req.header("x-client-request-id") ||
72
+ (req as any).prompt_cache_key ||
73
+ (typeof (body as any)?.session_id === "string" ? (body as any).session_id : undefined);
68
74
  // Convert to Chat Completions format
69
75
  const chatRequest = responsesToChatCompletions(req, historyMessages);
70
76
 
71
77
  if (isStream) {
72
- // ============ STREAMING MODE ============
73
78
  const socket =
74
79
  (c.env as any)?.incoming?.socket || (c.req.raw as any)?.socket;
75
80
  if (socket && typeof socket.setNoDelay === "function") {
@@ -139,9 +144,11 @@ app.post("/v1/responses", async (c) => {
139
144
  ...(c.req.header("x-qwenproxy-chat-mode")
140
145
  ? { "x-qwenproxy-chat-mode": c.req.header("x-qwenproxy-chat-mode")! }
141
146
  : {}),
147
+ ...(responsesSessionId ? { "session-id": responsesSessionId, "x-session-id": responsesSessionId } : {}),
142
148
  },
143
149
  body: JSON.stringify({
144
150
  ...chatRequest,
151
+ ...(responsesSessionId ? { session_id: responsesSessionId } : {}),
145
152
  stream: true,
146
153
  stream_options: { include_usage: true },
147
154
  }),
@@ -306,19 +313,14 @@ app.post("/v1/responses", async (c) => {
306
313
  ...(c.req.header("x-qwenproxy-chat-mode")
307
314
  ? { "x-qwenproxy-chat-mode": c.req.header("x-qwenproxy-chat-mode")! }
308
315
  : {}),
316
+ ...(responsesSessionId ? { "session-id": responsesSessionId, "x-session-id": responsesSessionId } : {}),
309
317
  },
310
- body: JSON.stringify(chatRequest),
318
+ body: JSON.stringify({
319
+ ...chatRequest,
320
+ ...(responsesSessionId ? { session_id: responsesSessionId } : {}),
321
+ }),
311
322
  },
312
323
  );
313
-
314
- if (!response.ok) {
315
- const errorText = await response.text();
316
- console.error(
317
- `[Responses] Upstream error: ${response.status} ${errorText}`,
318
- );
319
- return responsesError(c, "api_error", "Upstream service error", 502);
320
- }
321
-
322
324
  const chatResponse = await response.json();
323
325
  const responsesResponse = chatCompletionsToResponses(
324
326
  chatResponse,
@@ -61,7 +61,7 @@ import { loadAccounts, type QwenAccount } from "../core/accounts.ts";
61
61
  // acyclic, while the reverse direction would drag the browser layer into core.
62
62
  import { hasActiveAccountLease } from "../core/account-concurrency.ts";
63
63
  import { config } from "../core/config.ts";
64
- import { maskEmail } from "../core/logger.ts";
64
+ import { maskEmail, logger } from "../core/logger.ts";
65
65
  import { Mutex } from "../core/mutex.ts";
66
66
  import {
67
67
  markAccountHeadersReady,
@@ -184,6 +184,22 @@ async function recoverStuckAccountMutex(
184
184
  // account be initialized again instead of remaining permanently wedged.
185
185
  if (accountMutexes.get(accountId) !== mutex) return;
186
186
 
187
+ const lockState = mutex.state();
188
+ // A lock is only considered stuck if it has been held for at least PLAYWRIGHT_MUTEX_WAIT_MS (60s).
189
+ // A waiter with a short timeout (e.g. 5s) timing out does NOT mean the lock holder is stuck!
190
+ // Furthermore, never nuke an account while it is initializing (init: takes 20-30s)
191
+ // or while it is actively serving a stream to a user.
192
+ if (
193
+ lockState.heldForMs < PLAYWRIGHT_MUTEX_WAIT_MS ||
194
+ lockState.heldBy.startsWith("init:") ||
195
+ isAccountServingStream(accountId)
196
+ ) {
197
+ logger.warn(
198
+ `[Playwright] Skipping destructive mutex recovery | account=${accountId} | heldBy=${lockState.heldBy} | heldFor=${lockState.heldForMs}ms | limit=${PLAYWRIGHT_MUTEX_WAIT_MS}ms | servingStream=${isAccountServingStream(accountId)}`,
199
+ );
200
+ return;
201
+ }
202
+
187
203
  console.warn(
188
204
  `[Playwright] Recovering stuck account mutex | account=${accountId} | key=${key}`,
189
205
  );
@@ -795,7 +795,7 @@ async function withQwenBrowserPage<T>(
795
795
  return fn(page);
796
796
  },
797
797
  operationTimeoutMs,
798
- Math.min(config.timeouts.page, 5_000),
798
+ Math.min(config.timeouts.page, 30_000),
799
799
  recoverOnTimeout,
800
800
  );
801
801
  }