opencode-qwen-cli-auth 2.3.0 → 2.3.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/README.md CHANGED
@@ -7,6 +7,7 @@ OAuth plugin for [OpenCode](https://opencode.ai) to use Qwen for free via Qwen A
7
7
  - **OAuth 2.0 Device Authorization Grant** (RFC 8628) - login with your Qwen Account
8
8
  - **No API key required** - utilize Qwen's free tier
9
9
  - **Automatic token refresh** when expired
10
+ - **Multi-account support** - add multiple Qwen accounts and keep one active account
10
11
  - **DashScope compatibility** - automatically injects required headers for the OAuth flow
11
12
  - **Smart output token limit** - auto-caps tokens based on model (65K for coder-model, 8K for vision-model)
12
13
  - **Retry & Fallback** - handles quota/rate limit errors with payload degradation mechanism
@@ -44,6 +45,9 @@ Select provider **Qwen Code (qwen.ai OAuth)** and follow the instructions:
44
45
  2. Enter the provided code
45
46
  3. The plugin will automatically poll and save the token
46
47
 
48
+ To add more accounts, run `opencode auth login` again.
49
+ The plugin stores each successful login in the multi-account store and can auto-switch on quota exhaustion.
50
+
47
51
  ## Supported Models
48
52
 
49
53
  | Model | ID | Context | Max Output | Cost |
@@ -62,6 +66,8 @@ Select provider **Qwen Code (qwen.ai OAuth)** and follow the instructions:
62
66
  | `DEBUG_QWEN_PLUGIN=1` | Enable debug logging | Optional |
63
67
  | `ENABLE_PLUGIN_REQUEST_LOGGING=1` | Enable request logging to file | Optional |
64
68
  | `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1` | Enable CLI fallback on quota error | Optional |
69
+ | `OPENCODE_QWEN_ACCOUNTS_PATH` | Override multi-account store path (must be inside `~/.qwen`) | Optional |
70
+ | `OPENCODE_QWEN_QUOTA_COOLDOWN_MS` | Cooldown for exhausted accounts | Default: `86400000` (24h) |
65
71
 
66
72
  ### Debug & Logging
67
73
 
@@ -95,6 +101,8 @@ Log files are stored at: `~/.opencode/logs/qwen-plugin/`
95
101
  - **Format**: JSON with access_token, refresh_token, expiry_date, resource_url
96
102
  - **Auto-refresh**: Triggered when less than 30 seconds to expiration
97
103
  - **Lock mechanism**: Safe multi-process token refresh
104
+ - **Multi-account store**: `~/.qwen/oauth_accounts.json`
105
+ - **Multi-account lock**: `~/.qwen/oauth_accounts.lock`
98
106
 
99
107
  ### Required Headers
100
108
 
@@ -113,9 +121,10 @@ X-DashScope-UserAgent: opencode-qwen-cli-auth/{version}
113
121
 
114
122
  When hitting a `429 insufficient_quota` error, the plugin automatically:
115
123
 
116
- 1. **Degrades payload** - removes tools, reduces max_tokens to 1024
117
- 2. **Retries** - attempts request with degraded payload
118
- 3. **CLI fallback** (optional) - invokes `qwen` CLI if `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1` is set
124
+ 1. **Marks current account exhausted** for cooldown window
125
+ 2. **Switches to next healthy account** and retries with same payload
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
119
128
 
120
129
  ### Token Expiration
121
130
 
@@ -130,6 +139,9 @@ When hitting a `429 insufficient_quota` error, the plugin automatically:
130
139
  ```bash
131
140
  # View saved token
132
141
  cat ~/.qwen/oauth_creds.json
142
+
143
+ # View multi-account store
144
+ cat ~/.qwen/oauth_accounts.json
133
145
  ```
134
146
 
135
147
  ### Remove Authentication
package/README.vi.md CHANGED
@@ -7,6 +7,7 @@ Plugin OAuth cho [OpenCode](https://opencode.ai) để sử dụng Qwen miễn p
7
7
  - **OAuth 2.0 Device Authorization Grant** (RFC 8628) - đăng nhập bằng Qwen Account
8
8
  - **Không cần API key** - sử dụng free tier của Qwen
9
9
  - **Tự động refresh token** khi hết hạn
10
+ - **Hỗ trợ đa tài khoản** - thêm nhiều Qwen account và duy trì một tài khoản active
10
11
  - **Tương thích DashScope** - tự động inject headers cần thiết cho OAuth flow
11
12
  - **Giới hạn output token thông minh** - tự động cap theo model (65K cho coder-model, 8K cho vision-model)
12
13
  - **Retry & Fallback** - xử lý lỗi quota/rate limit với cơ chế degrade (giảm tải payload)
@@ -44,6 +45,9 @@ Chọn provider **Qwen Code (qwen.ai OAuth)** và làm theo hướng dẫn:
44
45
  2. Nhập mã code được cung cấp
45
46
  3. Plugin sẽ tự động poll và lưu token
46
47
 
48
+ Để thêm tài khoản mới, chạy lại `opencode auth login`.
49
+ Plugin sẽ lưu từng lần đăng nhập thành công vào kho đa tài khoản và có thể tự động đổi tài khoản khi hết quota.
50
+
47
51
  ## Models hỗ trợ
48
52
 
49
53
  | Model | ID | Context | Max Output | Chi phí |
@@ -62,6 +66,8 @@ Chọn provider **Qwen Code (qwen.ai OAuth)** và làm theo hướng dẫn:
62
66
  | `DEBUG_QWEN_PLUGIN=1` | Bật debug logging | Tùy chọn |
63
67
  | `ENABLE_PLUGIN_REQUEST_LOGGING=1` | Bật ghi log request ra file | Tùy chọn |
64
68
  | `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1` | Bật tính năng gọi CLI khi hết quota | Tùy chọn |
69
+ | `OPENCODE_QWEN_ACCOUNTS_PATH` | Ghi đè đường dẫn kho đa tài khoản (phải nằm trong `~/.qwen`) | Tùy chọn |
70
+ | `OPENCODE_QWEN_QUOTA_COOLDOWN_MS` | Thời gian cooldown cho tài khoản đã hết quota | Mặc định: `86400000` (24 giờ) |
65
71
 
66
72
  ### Debug & Logging
67
73
 
@@ -95,6 +101,8 @@ File log được lưu tại: `~/.opencode/logs/qwen-plugin/`
95
101
  - **Định dạng**: JSON chứa access_token, refresh_token, expiry_date, resource_url
96
102
  - **Tự động refresh**: Kích hoạt khi token còn dưới 30 giây là hết hạn
97
103
  - **Cơ chế khóa (Lock)**: Đảm bảo an toàn khi refresh token trong môi trường đa tiến trình (multi-process)
104
+ - **Kho đa tài khoản**: `~/.qwen/oauth_accounts.json`
105
+ - **Lock đa tài khoản**: `~/.qwen/oauth_accounts.lock`
98
106
 
99
107
  ### Headers Bắt buộc
100
108
 
@@ -113,9 +121,10 @@ X-DashScope-UserAgent: opencode-qwen-cli-auth/{version}
113
121
 
114
122
  Khi gặp lỗi `429 insufficient_quota`, plugin sẽ tự động:
115
123
 
116
- 1. **Degrade payload** - loại bỏ mảng tools, giảm max_tokens xuống 1024
117
- 2. **Retry** - thử gọi lại API với payload đã giảm tải
118
- 3. **CLI fallback** (tùy chọn) - gọi `qwen` CLI nếu biến `OPENCODE_QWEN_ENABLE_CLI_FALLBACK=1` được bật
124
+ 1. **Đánh dấu tài khoản hiện tại đã hết quota** trong cửa sổ cooldown
125
+ 2. **Đổi sang tài khoản khỏe tiếp theo** và retry với payload ban đầu
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
119
128
 
120
129
  ### Token Hết Hạn
121
130
 
@@ -130,6 +139,9 @@ Khi gặp lỗi `429 insufficient_quota`, plugin sẽ tự động:
130
139
  ```bash
131
140
  # Xem token đang được lưu
132
141
  cat ~/.qwen/oauth_creds.json
142
+
143
+ # Xem kho đa tài khoản
144
+ cat ~/.qwen/oauth_accounts.json
133
145
  ```
134
146
 
135
147
  ### Xóa xác thực
package/dist/index.js CHANGED
@@ -17,12 +17,12 @@
17
17
  import { randomUUID } from "node:crypto";
18
18
  import { spawn } from "node:child_process";
19
19
  import { existsSync } from "node:fs";
20
- import { createPKCE, requestDeviceCode, pollForToken, getApiBaseUrl, saveToken, refreshAccessToken, loadStoredToken, getValidToken } from "./lib/auth/auth.js";
20
+ import { createPKCE, requestDeviceCode, pollForToken, getApiBaseUrl, saveToken, refreshAccessToken, loadStoredToken, getValidToken, upsertOAuthAccount, getActiveOAuthAccount, markOAuthAccountQuotaExhausted, switchToNextHealthyOAuthAccount } from "./lib/auth/auth.js";
21
21
  import { PROVIDER_ID, AUTH_LABELS, DEVICE_FLOW, PORTAL_HEADERS } from "./lib/constants.js";
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 = 30000;
25
+ const CHAT_REQUEST_TIMEOUT_MS = 120000;
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) */
@@ -46,6 +46,7 @@ const DASH_SCOPE_OUTPUT_LIMITS = {
46
46
  "coder-model": 65536,
47
47
  "vision-model": 8192,
48
48
  };
49
+ let ACTIVE_OAUTH_ACCOUNT_ID = null;
49
50
  function capPayloadMaxTokens(payload) {
50
51
  if (!payload || typeof payload !== "object") {
51
52
  return payload;
@@ -125,7 +126,7 @@ function resolveQwenCliCommand() {
125
126
  return "qwen";
126
127
  }
127
128
  const QWEN_CLI_COMMAND = resolveQwenCliCommand();
128
- function shouldUseShell(command) {
129
+ function requiresShellExecution(command) {
129
130
  return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
130
131
  }
131
132
  function makeFailFastErrorResponse(status, code, message) {
@@ -252,6 +253,67 @@ function getHeaderValue(headers, headerName) {
252
253
  }
253
254
  return undefined;
254
255
  }
256
+
257
+ function applyAuthorizationHeader(requestInit, accessToken) {
258
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
259
+ return;
260
+ }
261
+ const bearer = `Bearer ${accessToken}`;
262
+ if (!requestInit.headers) {
263
+ requestInit.headers = { authorization: bearer };
264
+ return;
265
+ }
266
+ if (requestInit.headers instanceof Headers) {
267
+ requestInit.headers.set("authorization", bearer);
268
+ return;
269
+ }
270
+ if (Array.isArray(requestInit.headers)) {
271
+ const existing = requestInit.headers.findIndex(([name]) => String(name).toLowerCase() === "authorization");
272
+ if (existing >= 0) {
273
+ requestInit.headers[existing][1] = bearer;
274
+ return;
275
+ }
276
+ requestInit.headers.push(["authorization", bearer]);
277
+ return;
278
+ }
279
+ let existingKey = null;
280
+ for (const key of Object.keys(requestInit.headers)) {
281
+ if (key.toLowerCase() === "authorization") {
282
+ existingKey = key;
283
+ break;
284
+ }
285
+ }
286
+ if (existingKey) {
287
+ requestInit.headers[existingKey] = bearer;
288
+ return;
289
+ }
290
+ requestInit.headers.authorization = bearer;
291
+ }
292
+
293
+ function rewriteRequestBaseUrl(requestInput, resourceUrl) {
294
+ if (typeof requestInput !== "string" || typeof resourceUrl !== "string" || resourceUrl.length === 0) {
295
+ return requestInput;
296
+ }
297
+ try {
298
+ const targetBase = new URL(getApiBaseUrl(resourceUrl));
299
+ const current = new URL(requestInput);
300
+ const baseSegments = targetBase.pathname.split("/").filter(Boolean);
301
+ const currentSegments = current.pathname.split("/").filter(Boolean);
302
+ let suffix = currentSegments;
303
+ if (currentSegments.length >= baseSegments.length &&
304
+ baseSegments.every((segment, index) => currentSegments[index] === segment)) {
305
+ suffix = currentSegments.slice(baseSegments.length);
306
+ }
307
+ const mergedPath = [...baseSegments, ...suffix].join("/");
308
+ targetBase.pathname = `/${mergedPath}`.replace(/\/+/g, "/");
309
+ targetBase.search = current.search;
310
+ targetBase.hash = current.hash;
311
+ return targetBase.toString();
312
+ }
313
+ catch (_error) {
314
+ return requestInput;
315
+ }
316
+ }
255
317
  /**
256
318
  * Applies JSON request body with proper content-type header
257
319
  * @param {RequestInit} requestInit - Fetch options
@@ -311,10 +373,6 @@ function parseJsonRequestBody(requestInit) {
311
373
  catch (_error) {
312
374
  return null;
313
375
  }
314
- }
315
- catch (_error) {
316
- return null;
317
- }
318
376
  }
319
377
  /**
320
378
  * Removes client-only fields and caps max_tokens
@@ -357,24 +415,28 @@ function sanitizeOutgoingPayload(payload) {
357
415
  function createQuotaDegradedPayload(payload) {
358
416
  const degraded = { ...payload };
359
417
  let changed = false;
360
- // Remove tool-related fields
361
- if ("tools" in degraded) {
362
- delete degraded.tools;
363
- changed = true;
364
- }
365
- if ("tool_choice" in degraded) {
366
- delete degraded.tool_choice;
367
- changed = true;
368
- }
369
- if ("parallel_tool_calls" in degraded) {
370
- delete degraded.parallel_tool_calls;
371
- changed = true;
372
- }
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
+ // }
373
431
  // Disable streaming
374
432
  if (degraded.stream !== false) {
375
433
  degraded.stream = false;
376
434
  changed = true;
377
435
  }
436
+ if ("stream_options" in degraded) {
437
+ delete degraded.stream_options;
438
+ changed = true;
439
+ }
378
440
  // Reduce max_tokens
379
441
  if (typeof degraded.max_tokens !== "number" || degraded.max_tokens > QUOTA_DEGRADE_MAX_TOKENS) {
380
442
  degraded.max_tokens = QUOTA_DEGRADE_MAX_TOKENS;
@@ -533,7 +595,7 @@ function createSseResponseChunk(data) {
533
595
  * @param {boolean} streamMode - Whether to return streaming response
534
596
  * @returns {Response} Formatted completion response
535
597
  */
536
- function makeQwenCliCompletionResponse(model, content, context, streamMode) {
598
+ function makeQwenCliCompletionResponse(model, content, context, streamMode, abortSignal) {
537
599
  if (LOGGING_ENABLED) {
538
600
  logInfo("Qwen CLI fallback returned completion", {
539
601
  request_id: context.requestId,
@@ -547,7 +609,7 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
547
609
  const encoder = new TextEncoder();
548
610
  const stream = new ReadableStream({
549
611
  start(controller) {
550
- // Send first chunk with content
612
+ // Send first chunk with empty content
551
613
  controller.enqueue(encoder.encode(createSseResponseChunk({
552
614
  id: completionId,
553
615
  object: "chat.completion.chunk",
@@ -556,28 +618,51 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
556
618
  choices: [
557
619
  {
558
620
  index: 0,
559
- delta: { role: "assistant", content },
621
+ delta: { role: "assistant", content: "" },
560
622
  finish_reason: null,
561
623
  },
562
624
  ],
563
625
  })));
564
- // Send stop chunk
565
- controller.enqueue(encoder.encode(createSseResponseChunk({
566
- id: completionId,
567
- object: "chat.completion.chunk",
568
- created,
569
- model,
570
- choices: [
571
- {
572
- index: 0,
573
- delta: {},
574
- finish_reason: "stop",
575
- },
576
- ],
577
- })));
578
- // Send DONE marker
579
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
580
- controller.close();
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();
581
666
  },
582
667
  });
583
668
  return new Response(stream, {
@@ -639,6 +724,8 @@ async function runQwenCliFallback(payload, context, abortSignal) {
639
724
  command: QWEN_CLI_COMMAND,
640
725
  });
641
726
  }
727
+ // Use secure spawn logic across ALL OS, allowing .cmd locally on Windows by injecting shell correctly.
728
+ const isShellRequired = requiresShellExecution(QWEN_CLI_COMMAND);
642
729
  return await new Promise((resolve) => {
643
730
  let settled = false;
644
731
  let stdout = "";
@@ -646,7 +733,6 @@ async function runQwenCliFallback(payload, context, abortSignal) {
646
733
  let timer = null;
647
734
  let child = undefined;
648
735
  let abortHandler = undefined;
649
- const useShell = shouldUseShell(QWEN_CLI_COMMAND);
650
736
  const finalize = (result) => {
651
737
  if (settled) {
652
738
  return;
@@ -669,7 +755,7 @@ async function runQwenCliFallback(payload, context, abortSignal) {
669
755
  }
670
756
  try {
671
757
  child = spawn(QWEN_CLI_COMMAND, args, {
672
- shell: useShell,
758
+ shell: isShellRequired,
673
759
  windowsHide: true,
674
760
  stdio: ["ignore", "pipe", "pipe"],
675
761
  });
@@ -724,7 +810,7 @@ async function runQwenCliFallback(payload, context, abortSignal) {
724
810
  if (content) {
725
811
  finalize({
726
812
  ok: true,
727
- response: makeQwenCliCompletionResponse(model, content, context, streamMode),
813
+ response: makeQwenCliCompletionResponse(model, content, context, streamMode, abortSignal),
728
814
  });
729
815
  return;
730
816
  }
@@ -843,7 +929,7 @@ function applyDashScopeHeaders(requestInit) {
843
929
  */
844
930
  async function failFastFetch(input, init) {
845
931
  const normalized = await normalizeFetchInvocation(input, init);
846
- const requestInput = normalized.requestInput;
932
+ let requestInput = normalized.requestInput;
847
933
  const requestInit = normalized.requestInit;
848
934
  // Always inject DashScope OAuth headers at the fetch layer.
849
935
  // This ensures compatibility across OpenCode versions.
@@ -869,12 +955,14 @@ async function failFastFetch(input, init) {
869
955
  requestId: getHeaderValue(requestInit.headers, "x-request-id"),
870
956
  sessionID,
871
957
  modelID: typeof payload?.model === "string" ? payload.model : undefined,
958
+ accountID: ACTIVE_OAUTH_ACCOUNT_ID,
872
959
  };
873
960
  if (LOGGING_ENABLED) {
874
961
  logInfo("Qwen request dispatch", {
875
962
  request_id: context.requestId,
876
963
  sessionID: context.sessionID,
877
964
  modelID: context.modelID,
965
+ accountID: context.accountID,
878
966
  max_tokens: typeof payload?.max_tokens === "number" ? payload.max_tokens : undefined,
879
967
  max_completion_tokens: typeof payload?.max_completion_tokens === "number" ? payload.max_completion_tokens : undefined,
880
968
  message_count: Array.isArray(payload?.messages) ? payload.messages.length : undefined,
@@ -890,15 +978,47 @@ async function failFastFetch(input, init) {
890
978
  request_id: context.requestId,
891
979
  sessionID: context.sessionID,
892
980
  modelID: context.modelID,
981
+ accountID: context.accountID,
893
982
  status: response.status,
894
983
  attempt: retryAttempt + 1,
895
984
  });
896
985
  }
897
- const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
986
+ const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
898
987
  if (RETRYABLE_STATUS_CODES.includes(response.status)) {
899
988
  if (response.status === 429) {
900
989
  const firstBody = await response.text().catch(() => "");
901
990
  if (payload && isInsufficientQuota(firstBody)) {
991
+ if (context.accountID) {
992
+ try {
993
+ await markOAuthAccountQuotaExhausted(context.accountID, "insufficient_quota");
994
+ const switched = await switchToNextHealthyOAuthAccount([context.accountID]);
995
+ if (switched?.accessToken) {
996
+ const rotatedInit = { ...requestInit };
997
+ requestInput = rewriteRequestBaseUrl(requestInput, switched.resourceUrl);
998
+ applyAuthorizationHeader(rotatedInit, switched.accessToken);
999
+ applyAuthorizationHeader(requestInit, switched.accessToken);
1000
+ context.accountID = switched.accountId;
1001
+ ACTIVE_OAUTH_ACCOUNT_ID = switched.accountId;
1002
+ if (LOGGING_ENABLED) {
1003
+ logInfo("Switched OAuth account after insufficient_quota", {
1004
+ request_id: context.requestId,
1005
+ sessionID: context.sessionID,
1006
+ modelID: context.modelID,
1007
+ accountID: context.accountID,
1008
+ healthyAccounts: switched.healthyAccountCount,
1009
+ totalAccounts: switched.totalAccountCount,
1010
+ });
1011
+ }
1012
+ response = await sendWithTimeout(requestInput, rotatedInit);
1013
+ if (retryAttempt < MAX_REQUEST_RETRIES) {
1014
+ continue;
1015
+ }
1016
+ }
1017
+ }
1018
+ catch (switchError) {
1019
+ logWarn("Failed to switch OAuth account after insufficient_quota", switchError);
1020
+ }
1021
+ }
902
1022
  const degradedPayload = createQuotaDegradedPayload(payload);
903
1023
  if (degradedPayload) {
904
1024
  const fallbackInit = { ...requestInit };
@@ -989,25 +1109,39 @@ const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
989
1109
  * Uses getAuth() from SDK instead of reading file directly.
990
1110
  *
991
1111
  * @param {Function} getAuth - Function to get auth state from SDK
992
- * @returns {Promise<string|null>} Access token or null if not available
1112
+ * @returns {Promise<{ accessToken: string, resourceUrl?: string, accountId?: string }|null>} Access token state or null
993
1113
  */
994
1114
  async function getValidAccessToken(getAuth) {
1115
+ const activeOAuthAccount = await getActiveOAuthAccount({ allowExhausted: true });
1116
+ if (activeOAuthAccount?.accessToken) {
1117
+ return {
1118
+ accessToken: activeOAuthAccount.accessToken,
1119
+ resourceUrl: activeOAuthAccount.resourceUrl,
1120
+ accountId: activeOAuthAccount.accountId,
1121
+ };
1122
+ }
995
1123
  const diskToken = await getValidToken();
996
1124
  if (diskToken?.accessToken) {
997
- return diskToken.accessToken;
1125
+ return {
1126
+ accessToken: diskToken.accessToken,
1127
+ resourceUrl: diskToken.resourceUrl,
1128
+ };
998
1129
  }
999
1130
  const auth = await getAuth();
1000
1131
  if (!auth || auth.type !== "oauth") {
1001
1132
  return null;
1002
1133
  }
1003
1134
  let accessToken = auth.access;
1135
+ let resourceUrl = undefined;
1004
1136
  // Refresh if expired (60 second buffer)
1005
1137
  if (accessToken && auth.expires && Date.now() > auth.expires - 60000 && auth.refresh) {
1006
1138
  try {
1007
1139
  const refreshResult = await refreshAccessToken(auth.refresh);
1008
1140
  if (refreshResult.type === "success") {
1009
1141
  accessToken = refreshResult.access;
1142
+ resourceUrl = refreshResult.resourceUrl;
1010
1143
  saveToken(refreshResult);
1144
+ await upsertOAuthAccount(refreshResult, { setActive: false });
1011
1145
  }
1012
1146
  else {
1013
1147
  if (LOGGING_ENABLED) {
@@ -1025,18 +1159,27 @@ async function getValidAccessToken(getAuth) {
1025
1159
  }
1026
1160
  if (auth.access && auth.refresh) {
1027
1161
  try {
1028
- saveToken({
1162
+ const sdkToken = {
1029
1163
  type: "success",
1030
1164
  access: accessToken || auth.access,
1031
1165
  refresh: auth.refresh,
1032
1166
  expires: typeof auth.expires === "number" ? auth.expires : Date.now() + 3600 * 1000,
1033
- });
1167
+ resourceUrl,
1168
+ };
1169
+ saveToken(sdkToken);
1170
+ await upsertOAuthAccount(sdkToken, { setActive: false });
1034
1171
  }
1035
1172
  catch (e) {
1036
1173
  logWarn("Failed to bootstrap .qwen token from SDK auth state:", e);
1037
1174
  }
1038
1175
  }
1039
- return accessToken ?? null;
1176
+ if (!accessToken) {
1177
+ return null;
1178
+ }
1179
+ return {
1180
+ accessToken,
1181
+ resourceUrl,
1182
+ };
1040
1183
  }
1041
1184
 
1042
1185
  /**
@@ -1044,7 +1187,10 @@ async function getValidAccessToken(getAuth) {
1044
1187
  * Falls back to DashScope compatible-mode if not available.
1045
1188
  * @returns {string} DashScope API base URL
1046
1189
  */
1047
- function getBaseUrl() {
1190
+ function getBaseUrl(resourceUrl) {
1191
+ if (typeof resourceUrl === "string" && resourceUrl.length > 0) {
1192
+ return getApiBaseUrl(resourceUrl);
1193
+ }
1048
1194
  try {
1049
1195
  const stored = loadStoredToken();
1050
1196
  if (stored?.resource_url) {
@@ -1087,14 +1233,15 @@ export const QwenAuthPlugin = async (_input) => {
1087
1233
  if (model) model.cost = { input: 0, output: 0 };
1088
1234
  }
1089
1235
  }
1090
- const accessToken = await getValidAccessToken(getAuth);
1091
- if (!accessToken) return null;
1092
- const baseURL = getBaseUrl();
1236
+ const tokenState = await getValidAccessToken(getAuth);
1237
+ if (!tokenState?.accessToken) return null;
1238
+ ACTIVE_OAUTH_ACCOUNT_ID = tokenState.accountId || null;
1239
+ const baseURL = getBaseUrl(tokenState.resourceUrl);
1093
1240
  if (LOGGING_ENABLED) {
1094
1241
  logInfo("Using Qwen baseURL:", baseURL);
1095
1242
  }
1096
1243
  return {
1097
- apiKey: accessToken,
1244
+ apiKey: tokenState.accessToken,
1098
1245
  baseURL,
1099
1246
  timeout: CHAT_REQUEST_TIMEOUT_MS,
1100
1247
  maxRetries: CHAT_MAX_RETRIES,
@@ -1138,6 +1285,8 @@ export const QwenAuthPlugin = async (_input) => {
1138
1285
  const result = await pollForToken(deviceAuth.device_code, pkce.verifier);
1139
1286
  if (result.type === "success") {
1140
1287
  saveToken(result);
1288
+ const savedAccount = await upsertOAuthAccount(result, { setActive: true });
1289
+ ACTIVE_OAUTH_ACCOUNT_ID = savedAccount?.accountId || ACTIVE_OAUTH_ACCOUNT_ID;
1141
1290
  // Return to SDK to save auth state
1142
1291
  return {
1143
1292
  type: "success",
@@ -38,6 +38,56 @@ export declare function clearStoredToken(): void;
38
38
  * @param tokenResult - Token result from OAuth flow
39
39
  */
40
40
  export declare function saveToken(tokenResult: TokenResult): void;
41
+ /**
42
+ * Upsert OAuth account into ~/.qwen/oauth_accounts.json
43
+ */
44
+ export declare function upsertOAuthAccount(tokenResult: TokenResult, options?: {
45
+ accountId?: string;
46
+ accountKey?: string;
47
+ setActive?: boolean;
48
+ }): Promise<{
49
+ accountId: string;
50
+ accessToken: string;
51
+ resourceUrl?: string;
52
+ exhaustedUntil: number;
53
+ healthyAccountCount: number;
54
+ totalAccountCount: number;
55
+ } | null>;
56
+ /**
57
+ * Get active OAuth account token from multi-account store
58
+ */
59
+ export declare function getActiveOAuthAccount(options?: {
60
+ allowExhausted?: boolean;
61
+ requireHealthy?: boolean;
62
+ preferredAccountId?: string;
63
+ }): Promise<{
64
+ accountId: string;
65
+ accessToken: string;
66
+ resourceUrl?: string;
67
+ exhaustedUntil: number;
68
+ healthyAccountCount: number;
69
+ totalAccountCount: number;
70
+ } | null>;
71
+ /**
72
+ * Mark account as exhausted by insufficient_quota
73
+ */
74
+ export declare function markOAuthAccountQuotaExhausted(accountId: string, errorCode?: string): Promise<{
75
+ accountId: string;
76
+ exhaustedUntil: number;
77
+ healthyAccountCount: number;
78
+ totalAccountCount: number;
79
+ } | null>;
80
+ /**
81
+ * Switch active account to next healthy one
82
+ */
83
+ export declare function switchToNextHealthyOAuthAccount(excludedAccountIds?: string[]): Promise<{
84
+ accountId: string;
85
+ accessToken: string;
86
+ resourceUrl?: string;
87
+ exhaustedUntil: number;
88
+ healthyAccountCount: number;
89
+ totalAccountCount: number;
90
+ } | null>;
41
91
  /**
42
92
  * Check if token is expired (with 5 minute buffer)
43
93
  * @param expiresAt - Expiration timestamp in milliseconds
@@ -62,4 +112,4 @@ export declare function getValidToken(): Promise<{
62
112
  * - Chat API: /v1/ (for completions)
63
113
  */
64
114
  export declare function getApiBaseUrl(resourceUrl?: string): string;
65
- //# sourceMappingURL=auth.d.ts.map
115
+ //# sourceMappingURL=auth.d.ts.map
@@ -8,7 +8,7 @@
8
8
  import { generatePKCE } from "@openauthjs/openauth/pkce";
9
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, statSync } from "fs";
10
10
  import { QWEN_OAUTH, DEFAULT_QWEN_BASE_URL, TOKEN_REFRESH_BUFFER_MS, VERIFICATION_URI } from "../constants.js";
11
- import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
11
+ import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath, getAccountsPath, getAccountsLockPath } from "../config.js";
12
12
  import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
13
13
 
14
14
  /** Maximum number of retries for token refresh operations */
@@ -27,6 +27,10 @@ const LOCK_BACKOFF_MULTIPLIER = 1.5;
27
27
  const LOCK_MAX_INTERVAL_MS = 2000;
28
28
  /** Maximum number of lock acquisition attempts */
29
29
  const LOCK_MAX_ATTEMPTS = 20;
30
+ /** Account schema version for ~/.qwen/oauth_accounts.json */
31
+ const ACCOUNT_STORE_VERSION = 1;
32
+ /** Default cooldown when account hits insufficient_quota */
33
+ const DEFAULT_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000;
30
34
 
31
35
  /**
32
36
  * Checks if an error is an AbortError (from AbortController)
@@ -172,6 +176,225 @@ function toStoredTokenData(data) {
172
176
  };
173
177
  }
174
178
 
179
+ function getQuotaCooldownMs() {
180
+ const raw = process.env.OPENCODE_QWEN_QUOTA_COOLDOWN_MS;
181
+ if (typeof raw !== "string" || raw.trim().length === 0) {
182
+ return DEFAULT_QUOTA_COOLDOWN_MS;
183
+ }
184
+ const parsed = Number(raw);
185
+ if (!Number.isFinite(parsed) || parsed < 1000) {
186
+ return DEFAULT_QUOTA_COOLDOWN_MS;
187
+ }
188
+ return Math.floor(parsed);
189
+ }
190
+
191
+ function normalizeAccountStore(raw) {
192
+ const fallback = {
193
+ version: ACCOUNT_STORE_VERSION,
194
+ activeAccountId: null,
195
+ accounts: [],
196
+ };
197
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
198
+ return fallback;
199
+ }
200
+ const input = raw;
201
+ const accounts = Array.isArray(input.accounts) ? input.accounts : [];
202
+ const normalizedAccounts = [];
203
+ for (const item of accounts) {
204
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
205
+ continue;
206
+ }
207
+ const token = toStoredTokenData(item.token);
208
+ if (!token) {
209
+ continue;
210
+ }
211
+ const id = typeof item.id === "string" && item.id.trim().length > 0
212
+ ? item.id.trim()
213
+ : `acct_${Math.random().toString(16).slice(2)}_${Date.now().toString(36)}`;
214
+ const createdAt = typeof item.createdAt === "number" && Number.isFinite(item.createdAt) ? item.createdAt : Date.now();
215
+ const updatedAt = typeof item.updatedAt === "number" && Number.isFinite(item.updatedAt) ? item.updatedAt : createdAt;
216
+ const exhaustedUntil = typeof item.exhaustedUntil === "number" && Number.isFinite(item.exhaustedUntil) ? item.exhaustedUntil : 0;
217
+ const lastErrorCode = typeof item.lastErrorCode === "string" ? item.lastErrorCode : undefined;
218
+ const accountKey = typeof item.accountKey === "string" && item.accountKey.trim().length > 0 ? item.accountKey.trim() : undefined;
219
+ normalizedAccounts.push({
220
+ id,
221
+ token,
222
+ resource_url: token.resource_url,
223
+ exhaustedUntil,
224
+ lastErrorCode,
225
+ accountKey,
226
+ createdAt,
227
+ updatedAt,
228
+ });
229
+ }
230
+ let activeAccountId = typeof input.activeAccountId === "string" && input.activeAccountId.length > 0 ? input.activeAccountId : null;
231
+ if (activeAccountId && !normalizedAccounts.some(account => account.id === activeAccountId)) {
232
+ activeAccountId = null;
233
+ }
234
+ if (!activeAccountId && normalizedAccounts.length > 0) {
235
+ activeAccountId = normalizedAccounts[0].id;
236
+ }
237
+ return {
238
+ version: ACCOUNT_STORE_VERSION,
239
+ activeAccountId,
240
+ accounts: normalizedAccounts,
241
+ };
242
+ }
243
+
244
+ function normalizeTokenResultToStored(tokenResult) {
245
+ if (!tokenResult || tokenResult.type !== "success") {
246
+ return null;
247
+ }
248
+ return toStoredTokenData({
249
+ access_token: tokenResult.access,
250
+ refresh_token: tokenResult.refresh,
251
+ token_type: "Bearer",
252
+ expiry_date: tokenResult.expires,
253
+ resource_url: tokenResult.resourceUrl,
254
+ });
255
+ }
256
+
257
+ function parseJwtPayloadSegment(token) {
258
+ if (typeof token !== "string") {
259
+ return null;
260
+ }
261
+ const parts = token.split(".");
262
+ if (parts.length < 2) {
263
+ return null;
264
+ }
265
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
266
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
267
+ try {
268
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
269
+ }
270
+ catch (_error) {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ function deriveAccountKeyFromToken(tokenData) {
276
+ if (!tokenData || typeof tokenData !== "object") {
277
+ return null;
278
+ }
279
+ const payload = parseJwtPayloadSegment(tokenData.access_token);
280
+ if (payload && typeof payload === "object") {
281
+ const candidates = ["sub", "uid", "user_id", "email", "username"];
282
+ for (const key of candidates) {
283
+ const value = payload[key];
284
+ if (typeof value === "string" && value.trim().length > 0) {
285
+ return `${key}:${value.trim()}`;
286
+ }
287
+ }
288
+ }
289
+ if (typeof tokenData.refresh_token === "string" && tokenData.refresh_token.length > 12) {
290
+ return `refresh:${tokenData.refresh_token}`;
291
+ }
292
+ return null;
293
+ }
294
+
295
+ function buildAccountEntry(tokenData, accountId, accountKey) {
296
+ const now = Date.now();
297
+ return {
298
+ id: accountId,
299
+ token: tokenData,
300
+ resource_url: tokenData.resource_url,
301
+ exhaustedUntil: 0,
302
+ lastErrorCode: undefined,
303
+ accountKey: accountKey || undefined,
304
+ createdAt: now,
305
+ updatedAt: now,
306
+ };
307
+ }
308
+
309
+ function writeAccountsStoreData(store) {
310
+ const qwenDir = getQwenDir();
311
+ if (!existsSync(qwenDir)) {
312
+ mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
313
+ }
314
+ const accountsPath = getAccountsPath();
315
+ const tempPath = `${accountsPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
316
+ const payload = {
317
+ version: ACCOUNT_STORE_VERSION,
318
+ activeAccountId: store.activeAccountId || null,
319
+ accounts: store.accounts.map(account => ({
320
+ id: account.id,
321
+ token: account.token,
322
+ resource_url: account.resource_url,
323
+ exhaustedUntil: account.exhaustedUntil || 0,
324
+ lastErrorCode: account.lastErrorCode,
325
+ accountKey: account.accountKey,
326
+ createdAt: account.createdAt,
327
+ updatedAt: account.updatedAt,
328
+ })),
329
+ };
330
+ try {
331
+ writeFileSync(tempPath, JSON.stringify(payload, null, 2), {
332
+ encoding: "utf-8",
333
+ mode: 0o600,
334
+ });
335
+ renameSync(tempPath, accountsPath);
336
+ }
337
+ catch (error) {
338
+ try {
339
+ if (existsSync(tempPath)) {
340
+ unlinkSync(tempPath);
341
+ }
342
+ }
343
+ catch (_cleanupError) {
344
+ }
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ function loadAccountsStoreData() {
350
+ const path = getAccountsPath();
351
+ if (!existsSync(path)) {
352
+ return normalizeAccountStore(null);
353
+ }
354
+ try {
355
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
356
+ return normalizeAccountStore(raw);
357
+ }
358
+ catch (error) {
359
+ logWarn("Failed to read oauth_accounts.json, using empty store", error);
360
+ return normalizeAccountStore(null);
361
+ }
362
+ }
363
+
364
+ function pickNextHealthyAccount(store, excludedIds = new Set(), now = Date.now()) {
365
+ const accounts = Array.isArray(store.accounts) ? store.accounts : [];
366
+ if (accounts.length === 0) {
367
+ return null;
368
+ }
369
+ const activeIndex = accounts.findIndex(account => account.id === store.activeAccountId);
370
+ for (let step = 1; step <= accounts.length; step += 1) {
371
+ const index = activeIndex >= 0 ? (activeIndex + step) % accounts.length : (step - 1);
372
+ const candidate = accounts[index];
373
+ if (!candidate || excludedIds.has(candidate.id)) {
374
+ continue;
375
+ }
376
+ if (typeof candidate.exhaustedUntil === "number" && candidate.exhaustedUntil > now) {
377
+ continue;
378
+ }
379
+ return candidate;
380
+ }
381
+ return null;
382
+ }
383
+
384
+ function countHealthyAccounts(store, now = Date.now()) {
385
+ return store.accounts.filter(account => !(typeof account.exhaustedUntil === "number" && account.exhaustedUntil > now)).length;
386
+ }
387
+
388
+ function syncAccountToLegacyTokenFile(account) {
389
+ writeStoredTokenData({
390
+ access_token: account.token.access_token,
391
+ refresh_token: account.token.refresh_token,
392
+ token_type: account.token.token_type || "Bearer",
393
+ expiry_date: account.token.expiry_date,
394
+ resource_url: account.resource_url,
395
+ });
396
+ }
397
+
175
398
  /**
176
399
  * Builds token success object from stored token data
177
400
  * @param {Object} stored - Stored token data from file
@@ -251,6 +474,37 @@ function migrateLegacyTokenIfNeeded() {
251
474
  logWarn("Failed to migrate legacy token:", error);
252
475
  }
253
476
  }
477
+
478
+ function migrateLegacyTokenToAccountsIfNeeded() {
479
+ const accountsPath = getAccountsPath();
480
+ if (existsSync(accountsPath)) {
481
+ return;
482
+ }
483
+ const legacyToken = loadStoredToken();
484
+ if (!legacyToken) {
485
+ return;
486
+ }
487
+ const tokenData = toStoredTokenData(legacyToken);
488
+ if (!tokenData) {
489
+ return;
490
+ }
491
+ const accountKey = deriveAccountKeyFromToken(tokenData);
492
+ const accountId = `acct_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`;
493
+ const store = normalizeAccountStore({
494
+ version: ACCOUNT_STORE_VERSION,
495
+ activeAccountId: accountId,
496
+ accounts: [buildAccountEntry(tokenData, accountId, accountKey)],
497
+ });
498
+ try {
499
+ writeAccountsStoreData(store);
500
+ if (LOGGING_ENABLED) {
501
+ logInfo("Migrated legacy oauth_creds.json to oauth_accounts.json");
502
+ }
503
+ }
504
+ catch (error) {
505
+ logWarn("Failed to migrate legacy token to oauth_accounts.json", error);
506
+ }
507
+ }
254
508
  /**
255
509
  * Acquires exclusive lock for token refresh to prevent concurrent refreshes
256
510
  * Uses file-based locking with exponential backoff retry strategy
@@ -259,6 +513,10 @@ function migrateLegacyTokenIfNeeded() {
259
513
  */
260
514
  async function acquireTokenLock() {
261
515
  const lockPath = getTokenLockPath();
516
+ const qwenDir = getQwenDir();
517
+ if (!existsSync(qwenDir)) {
518
+ mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
519
+ }
262
520
  const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
263
521
  let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
264
522
  for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
@@ -322,6 +580,82 @@ function releaseTokenLock(lockPath) {
322
580
  }
323
581
  }
324
582
  }
583
+
584
+ async function acquireAccountsLock() {
585
+ const lockPath = getAccountsLockPath();
586
+ const qwenDir = getQwenDir();
587
+ if (!existsSync(qwenDir)) {
588
+ mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
589
+ }
590
+ const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
591
+ let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
592
+ for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
593
+ try {
594
+ writeFileSync(lockPath, lockValue, {
595
+ encoding: "utf-8",
596
+ flag: "wx",
597
+ mode: 0o600,
598
+ });
599
+ return lockPath;
600
+ }
601
+ catch (error) {
602
+ if (!hasErrorCode(error, "EEXIST")) {
603
+ throw error;
604
+ }
605
+ try {
606
+ const stats = statSync(lockPath);
607
+ const ageMs = Date.now() - stats.mtimeMs;
608
+ if (ageMs > LOCK_TIMEOUT_MS) {
609
+ try {
610
+ unlinkSync(lockPath);
611
+ }
612
+ catch (staleError) {
613
+ if (!hasErrorCode(staleError, "ENOENT")) {
614
+ logWarn("Failed to remove stale accounts lock", staleError);
615
+ }
616
+ }
617
+ continue;
618
+ }
619
+ }
620
+ catch (statError) {
621
+ if (!hasErrorCode(statError, "ENOENT")) {
622
+ logWarn("Failed to inspect accounts lock file", statError);
623
+ }
624
+ }
625
+ await sleep(waitMs);
626
+ waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
627
+ }
628
+ }
629
+ throw new Error("Accounts lock timeout");
630
+ }
631
+
632
+ function releaseAccountsLock(lockPath) {
633
+ try {
634
+ unlinkSync(lockPath);
635
+ }
636
+ catch (error) {
637
+ if (!hasErrorCode(error, "ENOENT")) {
638
+ logWarn("Failed to release accounts lock file", error);
639
+ }
640
+ }
641
+ }
642
+
643
+ async function withAccountsStoreLock(mutator) {
644
+ const lockPath = await acquireAccountsLock();
645
+ try {
646
+ const store = loadAccountsStoreData();
647
+ const next = await mutator(store);
648
+ if (next && typeof next === "object") {
649
+ writeAccountsStoreData(next);
650
+ return next;
651
+ }
652
+ writeAccountsStoreData(store);
653
+ return store;
654
+ }
655
+ finally {
656
+ releaseAccountsLock(lockPath);
657
+ }
658
+ }
325
659
  /**
326
660
  * Requests device code from Qwen OAuth server
327
661
  * Initiates OAuth 2.0 Device Authorization Grant flow
@@ -694,6 +1028,217 @@ export function saveToken(tokenResult) {
694
1028
  throw error;
695
1029
  }
696
1030
  }
1031
+
1032
+ function buildRuntimeAccountResponse(account, healthyCount, totalCount, accessToken, resourceUrl) {
1033
+ return {
1034
+ accountId: account.id,
1035
+ accessToken,
1036
+ resourceUrl: resourceUrl || account.resource_url,
1037
+ exhaustedUntil: account.exhaustedUntil || 0,
1038
+ healthyAccountCount: healthyCount,
1039
+ totalAccountCount: totalCount,
1040
+ };
1041
+ }
1042
+
1043
+ export async function upsertOAuthAccount(tokenResult, options = {}) {
1044
+ const tokenData = normalizeTokenResultToStored(tokenResult);
1045
+ if (!tokenData) {
1046
+ return null;
1047
+ }
1048
+ migrateLegacyTokenToAccountsIfNeeded();
1049
+ const accountKey = options.accountKey || deriveAccountKeyFromToken(tokenData);
1050
+ let selectedId = null;
1051
+ await withAccountsStoreLock((store) => {
1052
+ const now = Date.now();
1053
+ let index = -1;
1054
+ if (typeof options.accountId === "string" && options.accountId.length > 0) {
1055
+ index = store.accounts.findIndex(account => account.id === options.accountId);
1056
+ }
1057
+ if (index < 0 && accountKey) {
1058
+ index = store.accounts.findIndex(account => account.accountKey === accountKey);
1059
+ }
1060
+ if (index < 0) {
1061
+ index = store.accounts.findIndex(account => account.token?.refresh_token === tokenData.refresh_token);
1062
+ }
1063
+ if (index < 0) {
1064
+ const newId = typeof options.accountId === "string" && options.accountId.length > 0
1065
+ ? options.accountId
1066
+ : `acct_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`;
1067
+ store.accounts.push(buildAccountEntry(tokenData, newId, accountKey));
1068
+ index = store.accounts.length - 1;
1069
+ }
1070
+ const target = store.accounts[index];
1071
+ target.token = tokenData;
1072
+ target.resource_url = tokenData.resource_url;
1073
+ target.exhaustedUntil = 0;
1074
+ target.lastErrorCode = undefined;
1075
+ target.updatedAt = now;
1076
+ if (!target.createdAt || !Number.isFinite(target.createdAt)) {
1077
+ target.createdAt = now;
1078
+ }
1079
+ if (accountKey) {
1080
+ target.accountKey = accountKey;
1081
+ }
1082
+ selectedId = target.id;
1083
+ if (options.setActive || !store.activeAccountId) {
1084
+ store.activeAccountId = target.id;
1085
+ }
1086
+ return store;
1087
+ });
1088
+ if (!selectedId) {
1089
+ return null;
1090
+ }
1091
+ if (options.setActive) {
1092
+ return getActiveOAuthAccount({ allowExhausted: true, preferredAccountId: selectedId });
1093
+ }
1094
+ return getActiveOAuthAccount({ allowExhausted: true });
1095
+ }
1096
+
1097
+ export async function getActiveOAuthAccount(options = {}) {
1098
+ migrateLegacyTokenToAccountsIfNeeded();
1099
+ const lockPath = await acquireAccountsLock();
1100
+ let selected = null;
1101
+ let dirty = false;
1102
+ try {
1103
+ const store = loadAccountsStoreData();
1104
+ const now = Date.now();
1105
+ if (store.accounts.length === 0) {
1106
+ return null;
1107
+ }
1108
+ if (typeof options.preferredAccountId === "string" && options.preferredAccountId.length > 0) {
1109
+ const exists = store.accounts.some(account => account.id === options.preferredAccountId);
1110
+ if (exists && store.activeAccountId !== options.preferredAccountId) {
1111
+ store.activeAccountId = options.preferredAccountId;
1112
+ dirty = true;
1113
+ }
1114
+ }
1115
+ let active = store.accounts.find(account => account.id === store.activeAccountId);
1116
+ if (!active) {
1117
+ active = store.accounts[0];
1118
+ store.activeAccountId = active.id;
1119
+ dirty = true;
1120
+ }
1121
+ const activeHealthy = !(typeof active.exhaustedUntil === "number" && active.exhaustedUntil > now);
1122
+ if (!activeHealthy && !options.allowExhausted) {
1123
+ const replacement = pickNextHealthyAccount(store, new Set(), now);
1124
+ if (!replacement) {
1125
+ return null;
1126
+ }
1127
+ if (store.activeAccountId !== replacement.id) {
1128
+ store.activeAccountId = replacement.id;
1129
+ dirty = true;
1130
+ }
1131
+ active = replacement;
1132
+ }
1133
+ const healthyCount = countHealthyAccounts(store, now);
1134
+ selected = {
1135
+ account: { ...active },
1136
+ healthyCount,
1137
+ totalCount: store.accounts.length,
1138
+ };
1139
+ if (dirty) {
1140
+ writeAccountsStoreData(store);
1141
+ }
1142
+ }
1143
+ finally {
1144
+ releaseAccountsLock(lockPath);
1145
+ }
1146
+ if (!selected) {
1147
+ return null;
1148
+ }
1149
+ if (options.requireHealthy && selected.account.exhaustedUntil > Date.now()) {
1150
+ return null;
1151
+ }
1152
+ try {
1153
+ syncAccountToLegacyTokenFile(selected.account);
1154
+ }
1155
+ catch (error) {
1156
+ logWarn("Failed to sync active account token to oauth_creds.json", error);
1157
+ return null;
1158
+ }
1159
+ const valid = await getValidToken();
1160
+ if (!valid) {
1161
+ return null;
1162
+ }
1163
+ const latest = loadStoredToken();
1164
+ if (latest) {
1165
+ try {
1166
+ await withAccountsStoreLock((store) => {
1167
+ const target = store.accounts.find(account => account.id === selected.account.id);
1168
+ if (!target) {
1169
+ return store;
1170
+ }
1171
+ target.token = latest;
1172
+ target.resource_url = latest.resource_url;
1173
+ target.updatedAt = Date.now();
1174
+ return store;
1175
+ });
1176
+ }
1177
+ catch (error) {
1178
+ logWarn("Failed to update account token from refreshed legacy token", error);
1179
+ }
1180
+ }
1181
+ return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
1182
+ }
1183
+
1184
+ export async function markOAuthAccountQuotaExhausted(accountId, errorCode = "insufficient_quota") {
1185
+ if (typeof accountId !== "string" || accountId.length === 0) {
1186
+ return null;
1187
+ }
1188
+ migrateLegacyTokenToAccountsIfNeeded();
1189
+ const cooldownMs = getQuotaCooldownMs();
1190
+ let outcome = null;
1191
+ await withAccountsStoreLock((store) => {
1192
+ const now = Date.now();
1193
+ const target = store.accounts.find(account => account.id === accountId);
1194
+ if (!target) {
1195
+ return store;
1196
+ }
1197
+ target.exhaustedUntil = now + cooldownMs;
1198
+ target.lastErrorCode = errorCode;
1199
+ target.updatedAt = now;
1200
+ if (store.activeAccountId === target.id) {
1201
+ const next = pickNextHealthyAccount(store, new Set([target.id]), now);
1202
+ if (next) {
1203
+ store.activeAccountId = next.id;
1204
+ }
1205
+ }
1206
+ outcome = {
1207
+ accountId: target.id,
1208
+ exhaustedUntil: target.exhaustedUntil,
1209
+ healthyAccountCount: countHealthyAccounts(store, now),
1210
+ totalAccountCount: store.accounts.length,
1211
+ };
1212
+ return store;
1213
+ });
1214
+ return outcome;
1215
+ }
1216
+
1217
+ export async function switchToNextHealthyOAuthAccount(excludedAccountIds = []) {
1218
+ migrateLegacyTokenToAccountsIfNeeded();
1219
+ const excluded = new Set(Array.isArray(excludedAccountIds)
1220
+ ? excludedAccountIds.filter(id => typeof id === "string" && id.length > 0)
1221
+ : []);
1222
+ let switchedId = null;
1223
+ await withAccountsStoreLock((store) => {
1224
+ const next = pickNextHealthyAccount(store, excluded, Date.now());
1225
+ if (!next) {
1226
+ return store;
1227
+ }
1228
+ store.activeAccountId = next.id;
1229
+ switchedId = next.id;
1230
+ return store;
1231
+ });
1232
+ if (!switchedId) {
1233
+ return null;
1234
+ }
1235
+ return getActiveOAuthAccount({
1236
+ allowExhausted: false,
1237
+ requireHealthy: true,
1238
+ preferredAccountId: switchedId,
1239
+ });
1240
+ }
1241
+
697
1242
  /**
698
1243
  * Checks if token is expired (with buffer)
699
1244
  * @param {number} expiresAt - Token expiry timestamp in milliseconds
@@ -29,6 +29,14 @@ export declare function getTokenPath(): string;
29
29
  * Get token lock path for multi-process refresh coordination
30
30
  */
31
31
  export declare function getTokenLockPath(): string;
32
+ /**
33
+ * Get multi-account storage path
34
+ */
35
+ export declare function getAccountsPath(): string;
36
+ /**
37
+ * Get multi-account lock path
38
+ */
39
+ export declare function getAccountsLockPath(): string;
32
40
  /**
33
41
  * Get legacy token storage path used by old plugin versions
34
42
  */
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { homedir } from "os";
8
- import { join } from "path";
8
+ import { join, resolve, relative, isAbsolute, parse, dirname } from "path";
9
9
  import { readFileSync, existsSync } from "fs";
10
10
 
11
11
  /**
@@ -25,6 +25,50 @@ export function getQwenDir() {
25
25
  return join(homedir(), ".qwen");
26
26
  }
27
27
 
28
+ const ACCOUNTS_FILENAME = "oauth_accounts.json";
29
+ const DEFAULT_ACCOUNTS_PATH = join(getQwenDir(), ACCOUNTS_FILENAME);
30
+
31
+ function isRootPath(pathValue) {
32
+ const parsed = parse(pathValue);
33
+ return pathValue === parsed.root;
34
+ }
35
+
36
+ function isPathInsideBase(pathValue, baseDir) {
37
+ const rel = relative(baseDir, pathValue);
38
+ if (rel === "") {
39
+ return true;
40
+ }
41
+ return !rel.startsWith("..") && !isAbsolute(rel);
42
+ }
43
+
44
+ function resolveAccountsPathFromEnv() {
45
+ const envPath = process.env.OPENCODE_QWEN_ACCOUNTS_PATH;
46
+ if (typeof envPath !== "string" || envPath.trim().length === 0) {
47
+ return null;
48
+ }
49
+ const trimmed = envPath.trim();
50
+ if (trimmed.includes("\0")) {
51
+ console.warn("[qwen-oauth-plugin] Ignoring OPENCODE_QWEN_ACCOUNTS_PATH with invalid null-byte");
52
+ return null;
53
+ }
54
+ const resolved = resolve(trimmed);
55
+ if (isRootPath(resolved)) {
56
+ console.warn("[qwen-oauth-plugin] Ignoring OPENCODE_QWEN_ACCOUNTS_PATH pointing to root path");
57
+ return null;
58
+ }
59
+ const baseDir = getQwenDir();
60
+ if (!isPathInsideBase(resolved, baseDir)) {
61
+ console.warn("[qwen-oauth-plugin] Ignoring OPENCODE_QWEN_ACCOUNTS_PATH outside ~/.qwen for safety");
62
+ return null;
63
+ }
64
+ const parsed = parse(resolved);
65
+ if (!parsed.base || parsed.base.length === 0) {
66
+ console.warn("[qwen-oauth-plugin] Ignoring OPENCODE_QWEN_ACCOUNTS_PATH without filename");
67
+ return null;
68
+ }
69
+ return resolved;
70
+ }
71
+
28
72
  /**
29
73
  * Get plugin configuration file path
30
74
  * @returns {string} Path to ~/.opencode/qwen/auth-config.json
@@ -96,6 +140,26 @@ export function getTokenLockPath() {
96
140
  return join(getQwenDir(), "oauth_creds.lock");
97
141
  }
98
142
 
143
+ /**
144
+ * Get multi-account storage path
145
+ * @returns {string} Path to ~/.qwen/oauth_accounts.json or OPENCODE_QWEN_ACCOUNTS_PATH override
146
+ */
147
+ export function getAccountsPath() {
148
+ return resolveAccountsPathFromEnv() || DEFAULT_ACCOUNTS_PATH;
149
+ }
150
+
151
+ /**
152
+ * Get multi-account lock path
153
+ * @returns {string} Path to ~/.qwen/oauth_accounts.lock (or sidecar lock for override path)
154
+ */
155
+ export function getAccountsLockPath() {
156
+ const accountsPath = getAccountsPath();
157
+ if (dirname(accountsPath) !== getQwenDir()) {
158
+ return `${accountsPath}.lock`;
159
+ }
160
+ return join(getQwenDir(), "oauth_accounts.lock");
161
+ }
162
+
99
163
  /**
100
164
  * Get legacy token storage path used by old plugin versions
101
165
  * Used for backward compatibility and token migration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-qwen-cli-auth",
3
- "version": "2.3.0",
3
+ "version": "2.3.3",
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",