opencode-qwen-cli-auth 2.2.9 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,33 +1,47 @@
1
1
  /**
2
- * Alibaba Qwen OAuth Authentication Plugin for opencode
3
- *
4
- * Simple plugin: handles OAuth login + provides apiKey/baseURL to SDK.
5
- * SDK handles streaming, headers, and request format.
6
- *
2
+ * @fileoverview Alibaba Qwen OAuth Authentication Plugin for opencode
3
+ * Main plugin entry point implementing OAuth 2.0 Device Authorization Grant
4
+ * Handles authentication, request transformation, and error recovery
5
+ *
6
+ * Architecture:
7
+ * - OAuth flow: PKCE + Device Code Grant (RFC 8628)
8
+ * - Token management: Automatic refresh with file-based storage
9
+ * - Request handling: Custom fetch wrapper with retry logic
10
+ * - Error recovery: Quota degradation and CLI fallback
11
+ *
7
12
  * @license MIT with Usage Disclaimer (see LICENSE file)
8
13
  * @repository https://github.com/TVD-00/opencode-qwen-cli-auth
14
+ * @version 2.2.9
9
15
  */
16
+
10
17
  import { randomUUID } from "node:crypto";
11
18
  import { spawn } from "node:child_process";
12
19
  import { existsSync } from "node:fs";
13
20
  import { createPKCE, requestDeviceCode, pollForToken, getApiBaseUrl, saveToken, refreshAccessToken, loadStoredToken, getValidToken } from "./lib/auth/auth.js";
14
21
  import { PROVIDER_ID, AUTH_LABELS, DEVICE_FLOW, PORTAL_HEADERS } from "./lib/constants.js";
15
22
  import { logError, logInfo, logWarn, LOGGING_ENABLED } from "./lib/logger.js";
23
+
24
+ /** Request timeout for chat completions in milliseconds */
16
25
  const CHAT_REQUEST_TIMEOUT_MS = 30000;
17
- const CHAT_MAX_RETRIES = 0;
18
- // Output token caps should match what qwen-code uses for DashScope.
19
- // - coder-model: 64K output
20
- // - vision-model: 8K output
21
- // We still keep a default for safety.
26
+ /** Maximum number of retry attempts for failed requests */
27
+ const CHAT_MAX_RETRIES = 3;
28
+ /** Output token cap for coder-model (64K tokens) */
22
29
  const CHAT_MAX_TOKENS_CAP = 65536;
30
+ /** Default max tokens for chat requests */
23
31
  const CHAT_DEFAULT_MAX_TOKENS = 2048;
32
+ /** Maximum consecutive polling failures before aborting OAuth flow */
24
33
  const MAX_CONSECUTIVE_POLL_FAILURES = 3;
34
+ /** Reduced max tokens for quota degraded requests */
25
35
  const QUOTA_DEGRADE_MAX_TOKENS = 1024;
36
+ /** Timeout for CLI fallback execution in milliseconds */
26
37
  const CLI_FALLBACK_TIMEOUT_MS = 8000;
38
+ /** Maximum buffer size for CLI output in characters */
27
39
  const CLI_FALLBACK_MAX_BUFFER_CHARS = 1024 * 1024;
40
+ /** Enable CLI fallback feature via environment variable */
28
41
  const ENABLE_CLI_FALLBACK = process.env.OPENCODE_QWEN_ENABLE_CLI_FALLBACK === "1";
42
+ /** User agent string for plugin identification */
29
43
  const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.2.1";
30
- // Match qwen-code output limits for DashScope OAuth.
44
+ /** Output token limits per model for DashScope OAuth */
31
45
  const DASH_SCOPE_OUTPUT_LIMITS = {
32
46
  "coder-model": 65536,
33
47
  "vision-model": 8192,
@@ -127,6 +141,14 @@ function makeFailFastErrorResponse(status, code, message) {
127
141
  headers: { "content-type": "application/json" },
128
142
  });
129
143
  }
144
+
145
+ /**
146
+ * Creates AbortSignal with timeout that composes with source signal
147
+ * Properly cleans up timers and event listeners
148
+ * @param {AbortSignal} [sourceSignal] - Original abort signal from caller
149
+ * @param {number} timeoutMs - Timeout in milliseconds
150
+ * @returns {{ signal: AbortSignal, cleanup: () => void }} Composed signal and cleanup function
151
+ */
130
152
  function createRequestSignalWithTimeout(sourceSignal, timeoutMs) {
131
153
  const controller = new AbortController();
132
154
  const timeoutId = setTimeout(() => controller.abort(new Error("request_timeout")), timeoutMs);
@@ -149,6 +171,13 @@ function createRequestSignalWithTimeout(sourceSignal, timeoutMs) {
149
171
  },
150
172
  };
151
173
  }
174
+
175
+ /**
176
+ * Appends text chunk with size limit to prevent memory overflow
177
+ * @param {string} current - Current text buffer
178
+ * @param {string} chunk - New chunk to append
179
+ * @returns {string} Combined text with size limit
180
+ */
152
181
  function appendLimitedText(current, chunk) {
153
182
  const next = current + chunk;
154
183
  if (next.length <= CLI_FALLBACK_MAX_BUFFER_CHARS) {
@@ -156,9 +185,22 @@ function appendLimitedText(current, chunk) {
156
185
  }
157
186
  return next.slice(next.length - CLI_FALLBACK_MAX_BUFFER_CHARS);
158
187
  }
188
+
189
+ /**
190
+ * Checks if value is a Request instance
191
+ * @param {*} value - Value to check
192
+ * @returns {boolean} True if value is a Request instance
193
+ */
159
194
  function isRequestInstance(value) {
160
195
  return typeof Request !== "undefined" && value instanceof Request;
161
196
  }
197
+
198
+ /**
199
+ * Normalizes fetch invocation from Request object or URL string
200
+ * @param {Request|string} input - Fetch input
201
+ * @param {RequestInit} [init] - Fetch options
202
+ * @returns {{ requestInput: *, requestInit: RequestInit }} Normalized fetch parameters
203
+ */
162
204
  async function normalizeFetchInvocation(input, init) {
163
205
  const requestInit = init ? { ...init } : {};
164
206
  let requestInput = input;
@@ -184,6 +226,13 @@ async function normalizeFetchInvocation(input, init) {
184
226
  }
185
227
  return { requestInput, requestInit };
186
228
  }
229
+
230
+ /**
231
+ * Gets header value from Headers object, array, or plain object
232
+ * @param {Headers|Array|Object} headers - Headers to search
233
+ * @param {string} headerName - Header name (case-insensitive)
234
+ * @returns {string|undefined} Header value or undefined
235
+ */
187
236
  function getHeaderValue(headers, headerName) {
188
237
  if (!headers) {
189
238
  return undefined;
@@ -203,6 +252,11 @@ function getHeaderValue(headers, headerName) {
203
252
  }
204
253
  return undefined;
205
254
  }
255
+ /**
256
+ * Applies JSON request body with proper content-type header
257
+ * @param {RequestInit} requestInit - Fetch options
258
+ * @param {Object} payload - Request payload
259
+ */
206
260
  function applyJsonRequestBody(requestInit, payload) {
207
261
  requestInit.body = JSON.stringify(payload);
208
262
  if (!requestInit.headers) {
@@ -233,6 +287,12 @@ function applyJsonRequestBody(requestInit, payload) {
233
287
  requestInit.headers["content-type"] = "application/json";
234
288
  }
235
289
  }
290
+
291
+ /**
292
+ * Parses JSON request body if content-type is application/json
293
+ * @param {RequestInit} requestInit - Fetch options
294
+ * @returns {Object|null} Parsed payload or null
295
+ */
236
296
  function parseJsonRequestBody(requestInit) {
237
297
  if (typeof requestInit.body !== "string") {
238
298
  return null;
@@ -252,19 +312,31 @@ function parseJsonRequestBody(requestInit) {
252
312
  return null;
253
313
  }
254
314
  }
315
+ catch (_error) {
316
+ return null;
317
+ }
318
+ }
319
+ /**
320
+ * Removes client-only fields and caps max_tokens
321
+ * @param {Object} payload - Request payload
322
+ * @returns {Object} Sanitized payload
323
+ */
255
324
  function sanitizeOutgoingPayload(payload) {
256
325
  const sanitized = { ...payload };
257
326
  let changed = false;
327
+ // Remove client-only fields
258
328
  for (const field of CLIENT_ONLY_BODY_FIELDS) {
259
329
  if (field in sanitized) {
260
330
  delete sanitized[field];
261
331
  changed = true;
262
332
  }
263
333
  }
334
+ // Remove stream_options if stream is not enabled
264
335
  if ("stream_options" in sanitized && sanitized.stream !== true) {
265
336
  delete sanitized.stream_options;
266
337
  changed = true;
267
338
  }
339
+ // Cap max_tokens fields
268
340
  if (typeof sanitized.max_tokens === "number" && sanitized.max_tokens > CHAT_MAX_TOKENS_CAP) {
269
341
  sanitized.max_tokens = CHAT_MAX_TOKENS_CAP;
270
342
  changed = true;
@@ -275,9 +347,17 @@ function sanitizeOutgoingPayload(payload) {
275
347
  }
276
348
  return changed ? sanitized : payload;
277
349
  }
350
+
351
+ /**
352
+ * Creates degraded payload for quota error recovery
353
+ * Removes tools and reduces max_tokens to 1024
354
+ * @param {Object} payload - Original payload
355
+ * @returns {Object|null} Degraded payload or null if no changes needed
356
+ */
278
357
  function createQuotaDegradedPayload(payload) {
279
358
  const degraded = { ...payload };
280
359
  let changed = false;
360
+ // Remove tool-related fields
281
361
  if ("tools" in degraded) {
282
362
  delete degraded.tools;
283
363
  changed = true;
@@ -290,10 +370,12 @@ function createQuotaDegradedPayload(payload) {
290
370
  delete degraded.parallel_tool_calls;
291
371
  changed = true;
292
372
  }
373
+ // Disable streaming
293
374
  if (degraded.stream !== false) {
294
375
  degraded.stream = false;
295
376
  changed = true;
296
377
  }
378
+ // Reduce max_tokens
297
379
  if (typeof degraded.max_tokens !== "number" || degraded.max_tokens > QUOTA_DEGRADE_MAX_TOKENS) {
298
380
  degraded.max_tokens = QUOTA_DEGRADE_MAX_TOKENS;
299
381
  changed = true;
@@ -304,6 +386,12 @@ function createQuotaDegradedPayload(payload) {
304
386
  }
305
387
  return changed ? degraded : null;
306
388
  }
389
+
390
+ /**
391
+ * Checks if response text contains insufficientQuota error
392
+ * @param {string} text - Response body text
393
+ * @returns {boolean} True if insufficient quota error
394
+ */
307
395
  function isInsufficientQuota(text) {
308
396
  if (!text) {
309
397
  return false;
@@ -317,6 +405,12 @@ function isInsufficientQuota(text) {
317
405
  return text.toLowerCase().includes("insufficient_quota");
318
406
  }
319
407
  }
408
+
409
+ /**
410
+ * Extracts text content from message (handles string or array format)
411
+ * @param {string|Array} content - Message content
412
+ * @returns {string} Extracted text
413
+ */
320
414
  function extractMessageText(content) {
321
415
  if (typeof content === "string") {
322
416
  return content.trim();
@@ -334,6 +428,11 @@ function extractMessageText(content) {
334
428
  return "";
335
429
  }).filter(Boolean).join("\n").trim();
336
430
  }
431
+ /**
432
+ * Builds prompt text from chat messages for CLI fallback
433
+ * @param {Object} payload - Request payload with messages
434
+ * @returns {string} Prompt text for qwen CLI
435
+ */
337
436
  function buildQwenCliPrompt(payload) {
338
437
  const messages = Array.isArray(payload?.messages) ? payload.messages : [];
339
438
  for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -356,6 +455,12 @@ function buildQwenCliPrompt(payload) {
356
455
  }).filter(Boolean).join("\n\n");
357
456
  return merged || "Please respond to the latest user request.";
358
457
  }
458
+
459
+ /**
460
+ * Parses qwen CLI JSON output events
461
+ * @param {string} rawOutput - Raw CLI output
462
+ * @returns {Array|null} Parsed events or null
463
+ */
359
464
  function parseQwenCliEvents(rawOutput) {
360
465
  const trimmed = rawOutput.trim();
361
466
  if (!trimmed) {
@@ -379,6 +484,12 @@ function parseQwenCliEvents(rawOutput) {
379
484
  }
380
485
  return null;
381
486
  }
487
+
488
+ /**
489
+ * Extracts response text from CLI events
490
+ * @param {Array} events - Parsed CLI events
491
+ * @returns {string|null} Extracted text or null
492
+ */
382
493
  function extractQwenCliText(events) {
383
494
  for (let index = events.length - 1; index >= 0; index -= 1) {
384
495
  const event = events[index];
@@ -404,9 +515,24 @@ function extractQwenCliText(events) {
404
515
  }
405
516
  return null;
406
517
  }
518
+ /**
519
+ * Creates SSE formatted chunk for streaming responses
520
+ * @param {Object} data - Data to stringify and send
521
+ * @returns {string} SSE formatted string chunk
522
+ */
407
523
  function createSseResponseChunk(data) {
408
524
  return `data: ${JSON.stringify(data)}\n\n`;
409
525
  }
526
+
527
+ /**
528
+ * Creates Response object matching OpenAI completion format
529
+ * Handles both streaming (SSE) and non-streaming responses
530
+ * @param {string} model - Model ID used
531
+ * @param {string} content - Completion text content
532
+ * @param {Object} context - Request context for logging
533
+ * @param {boolean} streamMode - Whether to return streaming response
534
+ * @returns {Response} Formatted completion response
535
+ */
410
536
  function makeQwenCliCompletionResponse(model, content, context, streamMode) {
411
537
  if (LOGGING_ENABLED) {
412
538
  logInfo("Qwen CLI fallback returned completion", {
@@ -421,6 +547,7 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
421
547
  const encoder = new TextEncoder();
422
548
  const stream = new ReadableStream({
423
549
  start(controller) {
550
+ // Send first chunk with content
424
551
  controller.enqueue(encoder.encode(createSseResponseChunk({
425
552
  id: completionId,
426
553
  object: "chat.completion.chunk",
@@ -434,6 +561,7 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
434
561
  },
435
562
  ],
436
563
  })));
564
+ // Send stop chunk
437
565
  controller.enqueue(encoder.encode(createSseResponseChunk({
438
566
  id: completionId,
439
567
  object: "chat.completion.chunk",
@@ -447,6 +575,7 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
447
575
  },
448
576
  ],
449
577
  })));
578
+ // Send DONE marker
450
579
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
451
580
  controller.close();
452
581
  },
@@ -460,6 +589,7 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
460
589
  },
461
590
  });
462
591
  }
592
+ // Non-streaming response format
463
593
  const body = {
464
594
  id: `chatcmpl-${randomUUID()}`,
465
595
  object: "chat.completion",
@@ -489,6 +619,13 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
489
619
  },
490
620
  });
491
621
  }
622
+ /**
623
+ * Executes qwen CLI as fallback when API quota is exceeded
624
+ * @param {Object} payload - Original request payload
625
+ * @param {Object} context - Request context for logging
626
+ * @param {AbortSignal} [abortSignal] - Abort controller signal
627
+ * @returns {Promise<{ ok: boolean, response?: Response, reason?: string, stdout?: string, stderr?: string }>} Fallback execution result
628
+ */
492
629
  async function runQwenCliFallback(payload, context, abortSignal) {
493
630
  const model = typeof payload?.model === "string" && payload.model.length > 0 ? payload.model : "coder-model";
494
631
  const streamMode = payload?.stream === true;
@@ -600,6 +737,14 @@ async function runQwenCliFallback(payload, context, abortSignal) {
600
737
  });
601
738
  });
602
739
  }
740
+
741
+ /**
742
+ * Creates Response object for quota/rate limit errors
743
+ * @param {string} text - Response body text
744
+ * @param {HeadersInit} sourceHeaders - Original response headers
745
+ * @param {Object} context - Request context for logging
746
+ * @returns {Response} Formatted error response
747
+ */
603
748
  function makeQuotaFailFastResponse(text, sourceHeaders, context) {
604
749
  const headers = new Headers(sourceHeaders);
605
750
  headers.set("content-type", "application/json");
@@ -625,6 +770,12 @@ function makeQuotaFailFastResponse(text, sourceHeaders, context) {
625
770
  headers,
626
771
  });
627
772
  }
773
+ /**
774
+ * Performs fetch request with timeout protection
775
+ * @param {Request|string} input - Fetch input
776
+ * @param {RequestInit} requestInit - Fetch options
777
+ * @returns {Promise<Response>} Fetch response
778
+ */
628
779
  async function sendWithTimeout(input, requestInit) {
629
780
  const composed = createRequestSignalWithTimeout(requestInit.signal, CHAT_REQUEST_TIMEOUT_MS);
630
781
  try {
@@ -637,6 +788,12 @@ async function sendWithTimeout(input, requestInit) {
637
788
  composed.cleanup();
638
789
  }
639
790
  }
791
+
792
+ /**
793
+ * Injects required DashScope OAuth headers into fetch request
794
+ * Ensures compatibility even if OpenCode doesn't call chat.headers hook
795
+ * @param {RequestInit} requestInit - Fetch options to modify
796
+ */
640
797
  function applyDashScopeHeaders(requestInit) {
641
798
  // Ensure required DashScope OAuth headers are always present.
642
799
  // This mirrors qwen-code (DashScopeOpenAICompatibleProvider.buildHeaders) behavior.
@@ -676,6 +833,14 @@ function applyDashScopeHeaders(requestInit) {
676
833
  }
677
834
  }
678
835
  }
836
+
837
+ /**
838
+ * Custom fetch wrapper for OpenCode SDK
839
+ * Handles token limits, DashScope headers, retries, and quota error fallback
840
+ * @param {Request|string} input - Fetch input
841
+ * @param {RequestInit} [init] - Fetch options
842
+ * @returns {Promise<Response>} API response or fallback response
843
+ */
679
844
  async function failFastFetch(input, init) {
680
845
  const normalized = await normalizeFetchInvocation(input, init);
681
846
  const requestInput = normalized.requestInput;
@@ -718,84 +883,93 @@ async function failFastFetch(input, init) {
718
883
  }
719
884
  try {
720
885
  let response = await sendWithTimeout(requestInput, requestInit);
721
- if (LOGGING_ENABLED) {
722
- logInfo("Qwen request response", {
723
- request_id: context.requestId,
724
- sessionID: context.sessionID,
725
- modelID: context.modelID,
726
- status: response.status,
727
- attempt: 1,
728
- });
729
- }
730
- if (response.status === 429) {
731
- const firstBody = await response.text().catch(() => "");
732
- if (payload && isInsufficientQuota(firstBody)) {
733
- const degradedPayload = createQuotaDegradedPayload(payload);
734
- if (degradedPayload) {
735
- const fallbackInit = { ...requestInit };
736
- applyJsonRequestBody(fallbackInit, degradedPayload);
737
- if (LOGGING_ENABLED) {
738
- logWarn("Retrying once with degraded payload after 429 insufficient_quota", {
739
- request_id: context.requestId,
740
- sessionID: context.sessionID,
741
- modelID: context.modelID,
742
- attempt: 2,
743
- });
744
- }
745
- response = await sendWithTimeout(requestInput, fallbackInit);
746
- if (LOGGING_ENABLED) {
747
- logInfo("Qwen request response", {
748
- request_id: context.requestId,
749
- sessionID: context.sessionID,
750
- modelID: context.modelID,
751
- status: response.status,
752
- attempt: 2,
753
- });
754
- }
755
- if (response.status !== 429) {
756
- return response;
757
- }
758
- const fallbackBody = await response.text().catch(() => "");
759
- if (ENABLE_CLI_FALLBACK) {
760
- const cliFallback = await runQwenCliFallback(payload, context, sourceSignal);
761
- if (cliFallback.ok) {
762
- return cliFallback.response;
763
- }
764
- if (cliFallback.reason === "cli_aborted") {
765
- return makeFailFastErrorResponse(400, "request_aborted", "Qwen request was aborted");
886
+ const MAX_REQUEST_RETRIES = 3;
887
+ for (let retryAttempt = 0; retryAttempt <= MAX_REQUEST_RETRIES; retryAttempt++) {
888
+ if (LOGGING_ENABLED) {
889
+ logInfo("Qwen request response", {
890
+ request_id: context.requestId,
891
+ sessionID: context.sessionID,
892
+ modelID: context.modelID,
893
+ status: response.status,
894
+ attempt: retryAttempt + 1,
895
+ });
896
+ }
897
+ const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
898
+ if (RETRYABLE_STATUS_CODES.includes(response.status)) {
899
+ if (response.status === 429) {
900
+ const firstBody = await response.text().catch(() => "");
901
+ if (payload && isInsufficientQuota(firstBody)) {
902
+ const degradedPayload = createQuotaDegradedPayload(payload);
903
+ if (degradedPayload) {
904
+ const fallbackInit = { ...requestInit };
905
+ applyJsonRequestBody(fallbackInit, degradedPayload);
906
+ if (LOGGING_ENABLED) {
907
+ logWarn(`Retrying with degraded payload after ${response.status} insufficient_quota, attempt ${retryAttempt + 2}/${MAX_REQUEST_RETRIES + 1}`, {
908
+ request_id: context.requestId,
909
+ sessionID: context.sessionID,
910
+ modelID: context.modelID,
911
+ });
912
+ }
913
+ response = await sendWithTimeout(requestInput, fallbackInit);
914
+ if (retryAttempt < MAX_REQUEST_RETRIES) {
915
+ continue;
916
+ }
917
+ const fallbackBody = await response.text().catch(() => "");
918
+ if (ENABLE_CLI_FALLBACK) {
919
+ const cliFallback = await runQwenCliFallback(payload, context, sourceSignal);
920
+ if (cliFallback.ok) {
921
+ return cliFallback.response;
922
+ }
923
+ if (cliFallback.reason === "cli_aborted") {
924
+ return makeFailFastErrorResponse(400, "request_aborted", "Qwen request was aborted");
925
+ }
926
+ if (LOGGING_ENABLED) {
927
+ logWarn("Qwen CLI fallback failed", {
928
+ request_id: context.requestId,
929
+ sessionID: context.sessionID,
930
+ modelID: context.modelID,
931
+ reason: cliFallback.reason,
932
+ stderr: cliFallback.stderr,
933
+ });
934
+ }
935
+ }
936
+ return makeQuotaFailFastResponse(fallbackBody, response.headers, context);
766
937
  }
767
- if (LOGGING_ENABLED) {
768
- logWarn("Qwen CLI fallback failed", {
769
- request_id: context.requestId,
770
- sessionID: context.sessionID,
771
- modelID: context.modelID,
772
- reason: cliFallback.reason,
773
- stderr: cliFallback.stderr,
774
- });
938
+ if (ENABLE_CLI_FALLBACK) {
939
+ const cliFallback = await runQwenCliFallback(payload, context, sourceSignal);
940
+ if (cliFallback.ok) {
941
+ return cliFallback.response;
942
+ }
943
+ if (cliFallback.reason === "cli_aborted") {
944
+ return makeFailFastErrorResponse(400, "request_aborted", "Qwen request was aborted");
945
+ }
946
+ if (LOGGING_ENABLED) {
947
+ logWarn("Qwen CLI fallback failed", {
948
+ request_id: context.requestId,
949
+ sessionID: context.sessionID,
950
+ modelID: context.modelID,
951
+ reason: cliFallback.reason,
952
+ stderr: cliFallback.stderr,
953
+ });
954
+ }
775
955
  }
776
956
  }
777
- return makeQuotaFailFastResponse(fallbackBody, response.headers, context);
957
+ return makeQuotaFailFastResponse(firstBody, response.headers, context);
778
958
  }
779
- if (ENABLE_CLI_FALLBACK) {
780
- const cliFallback = await runQwenCliFallback(payload, context, sourceSignal);
781
- if (cliFallback.ok) {
782
- return cliFallback.response;
783
- }
784
- if (cliFallback.reason === "cli_aborted") {
785
- return makeFailFastErrorResponse(400, "request_aborted", "Qwen request was aborted");
786
- }
959
+ if (retryAttempt < MAX_REQUEST_RETRIES) {
787
960
  if (LOGGING_ENABLED) {
788
- logWarn("Qwen CLI fallback failed", {
961
+ logWarn(`Retrying after ${response.status}, attempt ${retryAttempt + 2}/${MAX_REQUEST_RETRIES + 1}`, {
789
962
  request_id: context.requestId,
790
963
  sessionID: context.sessionID,
791
964
  modelID: context.modelID,
792
- reason: cliFallback.reason,
793
- stderr: cliFallback.stderr,
794
965
  });
795
966
  }
967
+ await new Promise(r => setTimeout(r, (retryAttempt + 1) * 1000));
968
+ response = await sendWithTimeout(requestInput, requestInit);
969
+ continue;
796
970
  }
797
971
  }
798
- return makeQuotaFailFastResponse(firstBody, response.headers, context);
972
+ return response;
799
973
  }
800
974
  return response;
801
975
  }
@@ -814,8 +988,8 @@ async function failFastFetch(input, init) {
814
988
  * Get valid access token from SDK auth state, refresh if expired.
815
989
  * Uses getAuth() from SDK instead of reading file directly.
816
990
  *
817
- * @param getAuth - Function to get auth state from SDK
818
- * @returns Access token or null
991
+ * @param {Function} getAuth - Function to get auth state from SDK
992
+ * @returns {Promise<string|null>} Access token or null if not available
819
993
  */
820
994
  async function getValidAccessToken(getAuth) {
821
995
  const diskToken = await getValidToken();
@@ -864,9 +1038,11 @@ async function getValidAccessToken(getAuth) {
864
1038
  }
865
1039
  return accessToken ?? null;
866
1040
  }
1041
+
867
1042
  /**
868
1043
  * Get base URL from token stored on disk (resource_url).
869
1044
  * Falls back to DashScope compatible-mode if not available.
1045
+ * @returns {string} DashScope API base URL
870
1046
  */
871
1047
  function getBaseUrl() {
872
1048
  try {
@@ -880,8 +1056,13 @@ function getBaseUrl() {
880
1056
  }
881
1057
  return getApiBaseUrl();
882
1058
  }
1059
+
883
1060
  /**
884
1061
  * Alibaba Qwen OAuth authentication plugin for opencode
1062
+ * Integrates Qwen OAuth device flow and API handling into opencode SDK
1063
+ *
1064
+ * @param {*} _input - Plugin initialization input
1065
+ * @returns {Promise<Object>} Plugin configuration and hooks
885
1066
  *
886
1067
  * @example
887
1068
  * ```json
@@ -1047,6 +1228,13 @@ export const QwenAuthPlugin = async (_input) => {
1047
1228
  };
1048
1229
  config.provider = providers;
1049
1230
  },
1231
+ /**
1232
+ * Apply dynamic chat parameters before sending request
1233
+ * Ensures tokens and timeouts don't exceed plugin limits
1234
+ *
1235
+ * @param {*} input - Original chat request parameters
1236
+ * @param {*} output - Final payload to be sent
1237
+ */
1050
1238
  "chat.params": async (input, output) => {
1051
1239
  try {
1052
1240
  output.options = output.options || {};
@@ -1092,6 +1280,9 @@ export const QwenAuthPlugin = async (_input) => {
1092
1280
  * Send DashScope headers like original CLI.
1093
1281
  * X-DashScope-CacheControl: enable prompt caching, reduce token consumption.
1094
1282
  * X-DashScope-AuthType: specify auth method for server.
1283
+ *
1284
+ * @param {*} input - Original chat request parameters
1285
+ * @param {*} output - Final payload to be sent
1095
1286
  */
1096
1287
  "chat.headers": async (input, output) => {
1097
1288
  try {