opencode-qwen-cli-auth 2.3.0 → 2.3.1

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: `1800000` (30 min) |
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: `1800000` (30 phút) |
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,7 +17,7 @@
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
 
@@ -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
@@ -375,6 +433,10 @@ function createQuotaDegradedPayload(payload) {
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;
@@ -639,6 +701,12 @@ async function runQwenCliFallback(payload, context, abortSignal) {
639
701
  command: QWEN_CLI_COMMAND,
640
702
  });
641
703
  }
704
+ if (requiresShellExecution(QWEN_CLI_COMMAND)) {
705
+ return {
706
+ ok: false,
707
+ reason: "cli_shell_execution_blocked_for_security",
708
+ };
709
+ }
642
710
  return await new Promise((resolve) => {
643
711
  let settled = false;
644
712
  let stdout = "";
@@ -646,7 +714,6 @@ async function runQwenCliFallback(payload, context, abortSignal) {
646
714
  let timer = null;
647
715
  let child = undefined;
648
716
  let abortHandler = undefined;
649
- const useShell = shouldUseShell(QWEN_CLI_COMMAND);
650
717
  const finalize = (result) => {
651
718
  if (settled) {
652
719
  return;
@@ -669,7 +736,7 @@ async function runQwenCliFallback(payload, context, abortSignal) {
669
736
  }
670
737
  try {
671
738
  child = spawn(QWEN_CLI_COMMAND, args, {
672
- shell: useShell,
739
+ shell: false,
673
740
  windowsHide: true,
674
741
  stdio: ["ignore", "pipe", "pipe"],
675
742
  });
@@ -843,7 +910,7 @@ function applyDashScopeHeaders(requestInit) {
843
910
  */
844
911
  async function failFastFetch(input, init) {
845
912
  const normalized = await normalizeFetchInvocation(input, init);
846
- const requestInput = normalized.requestInput;
913
+ let requestInput = normalized.requestInput;
847
914
  const requestInit = normalized.requestInit;
848
915
  // Always inject DashScope OAuth headers at the fetch layer.
849
916
  // This ensures compatibility across OpenCode versions.
@@ -869,12 +936,14 @@ async function failFastFetch(input, init) {
869
936
  requestId: getHeaderValue(requestInit.headers, "x-request-id"),
870
937
  sessionID,
871
938
  modelID: typeof payload?.model === "string" ? payload.model : undefined,
939
+ accountID: ACTIVE_OAUTH_ACCOUNT_ID,
872
940
  };
873
941
  if (LOGGING_ENABLED) {
874
942
  logInfo("Qwen request dispatch", {
875
943
  request_id: context.requestId,
876
944
  sessionID: context.sessionID,
877
945
  modelID: context.modelID,
946
+ accountID: context.accountID,
878
947
  max_tokens: typeof payload?.max_tokens === "number" ? payload.max_tokens : undefined,
879
948
  max_completion_tokens: typeof payload?.max_completion_tokens === "number" ? payload.max_completion_tokens : undefined,
880
949
  message_count: Array.isArray(payload?.messages) ? payload.messages.length : undefined,
@@ -890,6 +959,7 @@ async function failFastFetch(input, init) {
890
959
  request_id: context.requestId,
891
960
  sessionID: context.sessionID,
892
961
  modelID: context.modelID,
962
+ accountID: context.accountID,
893
963
  status: response.status,
894
964
  attempt: retryAttempt + 1,
895
965
  });
@@ -899,6 +969,37 @@ const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
899
969
  if (response.status === 429) {
900
970
  const firstBody = await response.text().catch(() => "");
901
971
  if (payload && isInsufficientQuota(firstBody)) {
972
+ if (context.accountID) {
973
+ try {
974
+ await markOAuthAccountQuotaExhausted(context.accountID, "insufficient_quota");
975
+ const switched = await switchToNextHealthyOAuthAccount([context.accountID]);
976
+ if (switched?.accessToken) {
977
+ const rotatedInit = { ...requestInit };
978
+ requestInput = rewriteRequestBaseUrl(requestInput, switched.resourceUrl);
979
+ applyAuthorizationHeader(rotatedInit, switched.accessToken);
980
+ applyAuthorizationHeader(requestInit, switched.accessToken);
981
+ context.accountID = switched.accountId;
982
+ ACTIVE_OAUTH_ACCOUNT_ID = switched.accountId;
983
+ if (LOGGING_ENABLED) {
984
+ logInfo("Switched OAuth account after insufficient_quota", {
985
+ request_id: context.requestId,
986
+ sessionID: context.sessionID,
987
+ modelID: context.modelID,
988
+ accountID: context.accountID,
989
+ healthyAccounts: switched.healthyAccountCount,
990
+ totalAccounts: switched.totalAccountCount,
991
+ });
992
+ }
993
+ response = await sendWithTimeout(requestInput, rotatedInit);
994
+ if (retryAttempt < MAX_REQUEST_RETRIES) {
995
+ continue;
996
+ }
997
+ }
998
+ }
999
+ catch (switchError) {
1000
+ logWarn("Failed to switch OAuth account after insufficient_quota", switchError);
1001
+ }
1002
+ }
902
1003
  const degradedPayload = createQuotaDegradedPayload(payload);
903
1004
  if (degradedPayload) {
904
1005
  const fallbackInit = { ...requestInit };
@@ -989,25 +1090,39 @@ const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
989
1090
  * Uses getAuth() from SDK instead of reading file directly.
990
1091
  *
991
1092
  * @param {Function} getAuth - Function to get auth state from SDK
992
- * @returns {Promise<string|null>} Access token or null if not available
1093
+ * @returns {Promise<{ accessToken: string, resourceUrl?: string, accountId?: string }|null>} Access token state or null
993
1094
  */
994
1095
  async function getValidAccessToken(getAuth) {
1096
+ const activeOAuthAccount = await getActiveOAuthAccount({ allowExhausted: true });
1097
+ if (activeOAuthAccount?.accessToken) {
1098
+ return {
1099
+ accessToken: activeOAuthAccount.accessToken,
1100
+ resourceUrl: activeOAuthAccount.resourceUrl,
1101
+ accountId: activeOAuthAccount.accountId,
1102
+ };
1103
+ }
995
1104
  const diskToken = await getValidToken();
996
1105
  if (diskToken?.accessToken) {
997
- return diskToken.accessToken;
1106
+ return {
1107
+ accessToken: diskToken.accessToken,
1108
+ resourceUrl: diskToken.resourceUrl,
1109
+ };
998
1110
  }
999
1111
  const auth = await getAuth();
1000
1112
  if (!auth || auth.type !== "oauth") {
1001
1113
  return null;
1002
1114
  }
1003
1115
  let accessToken = auth.access;
1116
+ let resourceUrl = undefined;
1004
1117
  // Refresh if expired (60 second buffer)
1005
1118
  if (accessToken && auth.expires && Date.now() > auth.expires - 60000 && auth.refresh) {
1006
1119
  try {
1007
1120
  const refreshResult = await refreshAccessToken(auth.refresh);
1008
1121
  if (refreshResult.type === "success") {
1009
1122
  accessToken = refreshResult.access;
1123
+ resourceUrl = refreshResult.resourceUrl;
1010
1124
  saveToken(refreshResult);
1125
+ await upsertOAuthAccount(refreshResult, { setActive: false });
1011
1126
  }
1012
1127
  else {
1013
1128
  if (LOGGING_ENABLED) {
@@ -1025,18 +1140,27 @@ async function getValidAccessToken(getAuth) {
1025
1140
  }
1026
1141
  if (auth.access && auth.refresh) {
1027
1142
  try {
1028
- saveToken({
1143
+ const sdkToken = {
1029
1144
  type: "success",
1030
1145
  access: accessToken || auth.access,
1031
1146
  refresh: auth.refresh,
1032
1147
  expires: typeof auth.expires === "number" ? auth.expires : Date.now() + 3600 * 1000,
1033
- });
1148
+ resourceUrl,
1149
+ };
1150
+ saveToken(sdkToken);
1151
+ await upsertOAuthAccount(sdkToken, { setActive: false });
1034
1152
  }
1035
1153
  catch (e) {
1036
1154
  logWarn("Failed to bootstrap .qwen token from SDK auth state:", e);
1037
1155
  }
1038
1156
  }
1039
- return accessToken ?? null;
1157
+ if (!accessToken) {
1158
+ return null;
1159
+ }
1160
+ return {
1161
+ accessToken,
1162
+ resourceUrl,
1163
+ };
1040
1164
  }
1041
1165
 
1042
1166
  /**
@@ -1044,7 +1168,10 @@ async function getValidAccessToken(getAuth) {
1044
1168
  * Falls back to DashScope compatible-mode if not available.
1045
1169
  * @returns {string} DashScope API base URL
1046
1170
  */
1047
- function getBaseUrl() {
1171
+ function getBaseUrl(resourceUrl) {
1172
+ if (typeof resourceUrl === "string" && resourceUrl.length > 0) {
1173
+ return getApiBaseUrl(resourceUrl);
1174
+ }
1048
1175
  try {
1049
1176
  const stored = loadStoredToken();
1050
1177
  if (stored?.resource_url) {
@@ -1087,14 +1214,15 @@ export const QwenAuthPlugin = async (_input) => {
1087
1214
  if (model) model.cost = { input: 0, output: 0 };
1088
1215
  }
1089
1216
  }
1090
- const accessToken = await getValidAccessToken(getAuth);
1091
- if (!accessToken) return null;
1092
- const baseURL = getBaseUrl();
1217
+ const tokenState = await getValidAccessToken(getAuth);
1218
+ if (!tokenState?.accessToken) return null;
1219
+ ACTIVE_OAUTH_ACCOUNT_ID = tokenState.accountId || null;
1220
+ const baseURL = getBaseUrl(tokenState.resourceUrl);
1093
1221
  if (LOGGING_ENABLED) {
1094
1222
  logInfo("Using Qwen baseURL:", baseURL);
1095
1223
  }
1096
1224
  return {
1097
- apiKey: accessToken,
1225
+ apiKey: tokenState.accessToken,
1098
1226
  baseURL,
1099
1227
  timeout: CHAT_REQUEST_TIMEOUT_MS,
1100
1228
  maxRetries: CHAT_MAX_RETRIES,
@@ -1138,6 +1266,8 @@ export const QwenAuthPlugin = async (_input) => {
1138
1266
  const result = await pollForToken(deviceAuth.device_code, pkce.verifier);
1139
1267
  if (result.type === "success") {
1140
1268
  saveToken(result);
1269
+ const savedAccount = await upsertOAuthAccount(result, { setActive: true });
1270
+ ACTIVE_OAUTH_ACCOUNT_ID = savedAccount?.accountId || ACTIVE_OAUTH_ACCOUNT_ID;
1141
1271
  // Return to SDK to save auth state
1142
1272
  return {
1143
1273
  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 = 30 * 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.1",
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",