opencode-qwen-cli-auth 2.3.3 → 2.3.5

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/README.md CHANGED
@@ -50,10 +50,10 @@ The plugin stores each successful login in the multi-account store and can auto-
50
50
 
51
51
  ## Supported Models
52
52
 
53
- | Model | ID | Context | Max Output | Cost |
54
- |-------|-----|---------|------------|---------|
55
- | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | 1M tokens | 65,536 tokens | Free |
56
- | Qwen VL Plus (Vision) | `vision-model` | 128K tokens | 8,192 tokens | Free |
53
+ | Model | ID | Input | Output | Context | Max Output | Cost |
54
+ |-------|-----|-------|--------|---------|------------|---------|
55
+ | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | text | text | 1M tokens | 65,536 tokens | Free |
56
+ | Qwen VL Plus (Vision) | `vision-model` | text, image | text | 128K tokens | 8,192 tokens | Free |
57
57
 
58
58
  ## Configuration
59
59
 
@@ -124,7 +124,8 @@ When hitting a `429 insufficient_quota` error, the plugin automatically:
124
124
  1. **Marks current account exhausted** for cooldown window
125
125
  2. **Switches to next healthy account** and retries with same payload
126
126
  3. **Degrades payload** if no healthy account can be switched
127
- 4. **CLI fallback** (optional) - invokes `qwen` CLI if `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1` is set
127
+ 4. **CLI fallback** (optional) - invokes `qwen` CLI only for text-only payloads when `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1` is set
128
+ 5. **Multimodal safety guard** - skips CLI fallback for non-text parts (image/audio/file/video) to avoid semantic loss
128
129
 
129
130
  ### Token Expiration
130
131
 
package/README.vi.md CHANGED
@@ -50,10 +50,10 @@ Plugin sẽ lưu từng lần đăng nhập thành công vào kho đa tài kho
50
50
 
51
51
  ## Models hỗ trợ
52
52
 
53
- | Model | ID | Context | Max Output | Chi phí |
54
- |-------|-----|---------|------------|---------|
55
- | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | 1M tokens | 65,536 tokens | Miễn phí |
56
- | Qwen VL Plus (Vision) | `vision-model` | 128K tokens | 8,192 tokens | Miễn phí |
53
+ | Model | ID | Input | Output | Context | Max Output | Chi phí |
54
+ |-------|-----|-------|--------|---------|------------|---------|
55
+ | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | text | text | 1M tokens | 65,536 tokens | Miễn phí |
56
+ | Qwen VL Plus (Vision) | `vision-model` | text, image | text | 128K tokens | 8,192 tokens | Miễn phí |
57
57
 
58
58
  ## Cấu hình
59
59
 
@@ -124,7 +124,8 @@ Khi gặp lỗi `429 insufficient_quota`, plugin sẽ tự động:
124
124
  1. **Đánh dấu tài khoản hiện tại đã hết quota** trong cửa sổ cooldown
125
125
  2. **Đổi sang tài khoản khỏe tiếp theo** và retry với payload ban đầu
126
126
  3. **Degrade payload** nếu không còn tài khoản khỏe để đổi
127
- 4. **CLI fallback** (tùy chọn) - gọi `qwen` CLI nếu biến `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1` được bật
127
+ 4. **CLI fallback** (tùy chọn) - chỉ gọi `qwen` CLI cho payload chỉ có text khi bật `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1`
128
+ 5. **Guard multimodal an toàn** - bỏ qua CLI fallback khi payload có phần non-text (image/audio/file/video) để tránh mất ngữ nghĩa
128
129
 
129
130
  ### Token Hết Hạn
130
131
 
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * @license MIT with Usage Disclaimer (see LICENSE file)
13
13
  * @repository https://github.com/TVD-00/opencode-qwen-cli-auth
14
- * @version 2.2.9
14
+ * @version 2.3.5
15
15
  */
16
16
 
17
17
  import { randomUUID } from "node:crypto";
@@ -22,7 +22,7 @@ import { PROVIDER_ID, AUTH_LABELS, DEVICE_FLOW, PORTAL_HEADERS } from "./lib/con
22
22
  import { logError, logInfo, logWarn, LOGGING_ENABLED } from "./lib/logger.js";
23
23
 
24
24
  /** Request timeout for chat completions in milliseconds */
25
- const CHAT_REQUEST_TIMEOUT_MS = 120000;
25
+ const CHAT_REQUEST_TIMEOUT_MS = 30000;
26
26
  /** Maximum number of retry attempts for failed requests */
27
27
  const CHAT_MAX_RETRIES = 3;
28
28
  /** Output token cap for coder-model (64K tokens) */
@@ -40,7 +40,7 @@ const CLI_FALLBACK_MAX_BUFFER_CHARS = 1024 * 1024;
40
40
  /** Enable CLI fallback feature via environment variable */
41
41
  const ENABLE_CLI_FALLBACK = process.env.OPENCODE_QWEN_ENABLE_CLI_FALLBACK === "1";
42
42
  /** User agent string for plugin identification */
43
- const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.2.1";
43
+ const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.3.4";
44
44
  /** Output token limits per model for DashScope OAuth */
45
45
  const DASH_SCOPE_OUTPUT_LIMITS = {
46
46
  "coder-model": 65536,
@@ -415,19 +415,19 @@ function sanitizeOutgoingPayload(payload) {
415
415
  function createQuotaDegradedPayload(payload) {
416
416
  const degraded = { ...payload };
417
417
  let changed = false;
418
- // Remove tool-related fields (skip removing tools so Agents don't break)
419
- // if ("tools" in degraded) {
420
- // delete degraded.tools;
421
- // changed = true;
422
- // }
423
- // if ("tool_choice" in degraded) {
424
- // delete degraded.tool_choice;
425
- // changed = true;
426
- // }
427
- // if ("parallel_tool_calls" in degraded) {
428
- // delete degraded.parallel_tool_calls;
429
- // changed = true;
430
- // }
418
+ // Remove tool-related fields
419
+ if ("tools" in degraded) {
420
+ delete degraded.tools;
421
+ changed = true;
422
+ }
423
+ if ("tool_choice" in degraded) {
424
+ delete degraded.tool_choice;
425
+ changed = true;
426
+ }
427
+ if ("parallel_tool_calls" in degraded) {
428
+ delete degraded.parallel_tool_calls;
429
+ changed = true;
430
+ }
431
431
  // Disable streaming
432
432
  if (degraded.stream !== false) {
433
433
  degraded.stream = false;
@@ -490,6 +490,54 @@ function extractMessageText(content) {
490
490
  return "";
491
491
  }).filter(Boolean).join("\n").trim();
492
492
  }
493
+
494
+ /**
495
+ * Checks whether content contains non-text parts
496
+ * @param {*} content - Message content
497
+ * @returns {boolean} True if any non-text part is present
498
+ */
499
+ function hasNonTextContentPart(content) {
500
+ if (typeof content === "string") {
501
+ return false;
502
+ }
503
+ if (Array.isArray(content)) {
504
+ return content.some((part) => {
505
+ if (typeof part === "string") {
506
+ return false;
507
+ }
508
+ if (!part || typeof part !== "object") {
509
+ return true;
510
+ }
511
+ if (typeof part.text === "string") {
512
+ return false;
513
+ }
514
+ const partType = typeof part.type === "string" ? part.type.toLowerCase() : "";
515
+ if (partType === "text" && typeof part.text === "string") {
516
+ return false;
517
+ }
518
+ return true;
519
+ });
520
+ }
521
+ if (content && typeof content === "object") {
522
+ return typeof content.text !== "string";
523
+ }
524
+ return false;
525
+ }
526
+
527
+ /**
528
+ * Checks whether payload contains any multimodal message content
529
+ * @param {Object} payload - Request payload
530
+ * @returns {boolean} True if payload contains non-text message parts
531
+ */
532
+ function payloadContainsNonTextMessages(payload) {
533
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
534
+ for (const message of messages) {
535
+ if (hasNonTextContentPart(message?.content)) {
536
+ return true;
537
+ }
538
+ }
539
+ return false;
540
+ }
493
541
  /**
494
542
  * Builds prompt text from chat messages for CLI fallback
495
543
  * @param {Object} payload - Request payload with messages
@@ -595,7 +643,7 @@ function createSseResponseChunk(data) {
595
643
  * @param {boolean} streamMode - Whether to return streaming response
596
644
  * @returns {Response} Formatted completion response
597
645
  */
598
- function makeQwenCliCompletionResponse(model, content, context, streamMode, abortSignal) {
646
+ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
599
647
  if (LOGGING_ENABLED) {
600
648
  logInfo("Qwen CLI fallback returned completion", {
601
649
  request_id: context.requestId,
@@ -609,7 +657,7 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode, abor
609
657
  const encoder = new TextEncoder();
610
658
  const stream = new ReadableStream({
611
659
  start(controller) {
612
- // Send first chunk with empty content
660
+ // Send first chunk with content
613
661
  controller.enqueue(encoder.encode(createSseResponseChunk({
614
662
  id: completionId,
615
663
  object: "chat.completion.chunk",
@@ -618,51 +666,28 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode, abor
618
666
  choices: [
619
667
  {
620
668
  index: 0,
621
- delta: { role: "assistant", content: "" },
669
+ delta: { role: "assistant", content },
622
670
  finish_reason: null,
623
671
  },
624
672
  ],
625
673
  })));
626
-
627
- const CHUNK_SIZE = 15;
628
- const DELAY_MS = 20;
629
- let position = 0;
630
-
631
- function pushNextChunk() {
632
- if (abortSignal?.aborted) {
633
- try { controller.close(); } catch (e) { }
634
- return;
635
- }
636
-
637
- if (position >= content.length) {
638
- // Send stop chunk
639
- controller.enqueue(encoder.encode(createSseResponseChunk({
640
- id: completionId,
641
- object: "chat.completion.chunk",
642
- created,
643
- model,
644
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
645
- })));
646
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
647
- try { controller.close(); } catch (e) { }
648
- return;
649
- }
650
-
651
- const nextSlice = content.slice(position, position + CHUNK_SIZE);
652
- position += CHUNK_SIZE;
653
-
654
- controller.enqueue(encoder.encode(createSseResponseChunk({
655
- id: completionId,
656
- object: "chat.completion.chunk",
657
- created,
658
- model,
659
- choices: [{ index: 0, delta: { content: nextSlice }, finish_reason: null }],
660
- })));
661
-
662
- setTimeout(pushNextChunk, DELAY_MS);
663
- }
664
-
665
- pushNextChunk();
674
+ // Send stop chunk
675
+ controller.enqueue(encoder.encode(createSseResponseChunk({
676
+ id: completionId,
677
+ object: "chat.completion.chunk",
678
+ created,
679
+ model,
680
+ choices: [
681
+ {
682
+ index: 0,
683
+ delta: {},
684
+ finish_reason: "stop",
685
+ },
686
+ ],
687
+ })));
688
+ // Send DONE marker
689
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
690
+ controller.close();
666
691
  },
667
692
  });
668
693
  return new Response(stream, {
@@ -714,6 +739,20 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode, abor
714
739
  async function runQwenCliFallback(payload, context, abortSignal) {
715
740
  const model = typeof payload?.model === "string" && payload.model.length > 0 ? payload.model : "coder-model";
716
741
  const streamMode = payload?.stream === true;
742
+ if (payloadContainsNonTextMessages(payload)) {
743
+ if (LOGGING_ENABLED) {
744
+ logWarn("Skipping qwen CLI fallback for multimodal payload", {
745
+ request_id: context.requestId,
746
+ sessionID: context.sessionID,
747
+ modelID: model,
748
+ accountID: context.accountID,
749
+ });
750
+ }
751
+ return {
752
+ ok: false,
753
+ reason: "cli_fallback_unsupported_multimodal_payload",
754
+ };
755
+ }
717
756
  const prompt = buildQwenCliPrompt(payload);
718
757
  const args = [prompt, "-o", "json", "--max-session-turns", "1", "--model", model];
719
758
  if (LOGGING_ENABLED) {
@@ -724,8 +763,12 @@ async function runQwenCliFallback(payload, context, abortSignal) {
724
763
  command: QWEN_CLI_COMMAND,
725
764
  });
726
765
  }
727
- // Use secure spawn logic across ALL OS, allowing .cmd locally on Windows by injecting shell correctly.
728
- const isShellRequired = requiresShellExecution(QWEN_CLI_COMMAND);
766
+ if (requiresShellExecution(QWEN_CLI_COMMAND)) {
767
+ return {
768
+ ok: false,
769
+ reason: "cli_shell_execution_blocked_for_security",
770
+ };
771
+ }
729
772
  return await new Promise((resolve) => {
730
773
  let settled = false;
731
774
  let stdout = "";
@@ -755,7 +798,7 @@ async function runQwenCliFallback(payload, context, abortSignal) {
755
798
  }
756
799
  try {
757
800
  child = spawn(QWEN_CLI_COMMAND, args, {
758
- shell: isShellRequired,
801
+ shell: false,
759
802
  windowsHide: true,
760
803
  stdio: ["ignore", "pipe", "pipe"],
761
804
  });
@@ -810,7 +853,7 @@ async function runQwenCliFallback(payload, context, abortSignal) {
810
853
  if (content) {
811
854
  finalize({
812
855
  ok: true,
813
- response: makeQwenCliCompletionResponse(model, content, context, streamMode, abortSignal),
856
+ response: makeQwenCliCompletionResponse(model, content, context, streamMode),
814
857
  });
815
858
  return;
816
859
  }
@@ -1360,6 +1403,7 @@ export const QwenAuthPlugin = async (_input) => {
1360
1403
  name: "Qwen Coder (Qwen 3.5 Plus)",
1361
1404
  // Qwen does not support reasoning_effort from OpenCode UI
1362
1405
  // Thinking is always enabled by default on server side (qwen3.5-plus)
1406
+ attachment: false,
1363
1407
  reasoning: false,
1364
1408
  limit: { context: 1048576, output: CHAT_MAX_TOKENS_CAP },
1365
1409
  cost: { input: 0, output: 0 },
@@ -1368,10 +1412,11 @@ export const QwenAuthPlugin = async (_input) => {
1368
1412
  "vision-model": {
1369
1413
  id: "vision-model",
1370
1414
  name: "Qwen VL Plus (vision)",
1415
+ attachment: true,
1371
1416
  reasoning: false,
1372
1417
  limit: { context: 131072, output: DASH_SCOPE_OUTPUT_LIMITS["vision-model"] },
1373
1418
  cost: { input: 0, output: 0 },
1374
- modalities: { input: ["text"], output: ["text"] },
1419
+ modalities: { input: ["text", "image"], output: ["text"] },
1375
1420
  },
1376
1421
  },
1377
1422
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-qwen-cli-auth",
3
- "version": "2.3.3",
3
+ "version": "2.3.5",
4
4
  "description": "Qwen OAuth authentication plugin for opencode - use your Qwen account instead of API keys",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",