opencode-qwen-cli-auth 2.3.7 → 2.3.9

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
@@ -10,6 +10,8 @@ OAuth plugin for [OpenCode](https://opencode.ai) to use Qwen for free via Qwen A
10
10
  - **Multi-account support** - add multiple Qwen accounts and keep one active account
11
11
  - **DashScope compatibility** - automatically injects required headers for the OAuth flow
12
12
  - **Smart output token limit** - auto-caps tokens based on model (65K for coder-model, 8K for vision-model)
13
+ - **Reasoning capability in UI (coder-model)** - model tooltip shows reasoning support in OpenCode
14
+ - **Reasoning-effort safety** - strips reasoning control fields from outbound payload for OAuth compatibility
13
15
  - **Retry & Fallback** - handles quota/rate limit errors with payload degradation mechanism
14
16
  - **Logging & Debugging** - detailed debugging support via environment variables
15
17
 
@@ -55,6 +57,12 @@ The plugin stores each successful login in the multi-account store and can auto-
55
57
  | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | text | text | 1M tokens | 65,536 tokens | Free |
56
58
  | Qwen VL Plus (Vision) | `vision-model` | text, image | text | 128K tokens | 8,192 tokens | Free |
57
59
 
60
+ ### Reasoning Note
61
+
62
+ - `coder-model` is marked as reasoning-capable in OpenCode UI.
63
+ - This release is UI-only for reasoning and does not enable runtime reasoning-effort controls for Qwen OAuth.
64
+ - If clients send `reasoning`, `reasoningEffort`, or `reasoning_effort`, the plugin removes these fields before forwarding requests.
65
+
58
66
  ## Configuration
59
67
 
60
68
  ### Environment Variables
@@ -131,7 +139,8 @@ When hitting a `429 insufficient_quota` error, the plugin automatically:
131
139
 
132
140
  - Automatically uses refresh token
133
141
  - Retries up to 2 times for transient errors (timeout, network)
134
- - Clears token and requests re-auth on 401/403
142
+ - On refresh `401/403`, marks current account as `auth_invalid` and switches to next healthy account when available
143
+ - If no healthy account is available, requests re-authentication (`opencode auth login`)
135
144
 
136
145
  ## Authentication Management
137
146
 
package/README.vi.md CHANGED
@@ -10,6 +10,8 @@ Plugin OAuth cho [OpenCode](https://opencode.ai) để sử dụng Qwen miễn p
10
10
  - **Hỗ trợ đa tài khoản** - thêm nhiều Qwen account và duy trì một tài khoản active
11
11
  - **Tương thích DashScope** - tự động inject headers cần thiết cho OAuth flow
12
12
  - **Giới hạn output token thông minh** - tự động cap theo model (65K cho coder-model, 8K cho vision-model)
13
+ - **Hiển thị reasoning trên UI (coder-model)** - tooltip model trong OpenCode sẽ hiện hỗ trợ reasoning
14
+ - **An toàn reasoning-effort** - loại bỏ các trường điều khiển reasoning khỏi payload để giữ tương thích OAuth
13
15
  - **Retry & Fallback** - xử lý lỗi quota/rate limit với cơ chế degrade (giảm tải payload)
14
16
  - **Logging & Debugging** - hỗ trợ debug chi tiết qua biến môi trường
15
17
 
@@ -55,6 +57,12 @@ Plugin sẽ lưu từng lần đăng nhập thành công vào kho đa tài kho
55
57
  | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | text | text | 1M tokens | 65,536 tokens | Miễn phí |
56
58
  | Qwen VL Plus (Vision) | `vision-model` | text, image | text | 128K tokens | 8,192 tokens | Miễn phí |
57
59
 
60
+ ### Ghi chú reasoning
61
+
62
+ - `coder-model` được đánh dấu có reasoning trong UI của OpenCode.
63
+ - Bản phát hành này chỉ hỗ trợ reasoning ở mức hiển thị UI, chưa bật điều khiển reasoning-effort ở runtime cho Qwen OAuth.
64
+ - Nếu client gửi `reasoning`, `reasoningEffort` hoặc `reasoning_effort`, plugin sẽ tự loại bỏ trước khi gửi request đi.
65
+
58
66
  ## Cấu hình
59
67
 
60
68
  ### Biến môi trường
@@ -131,7 +139,8 @@ Khi gặp lỗi `429 insufficient_quota`, plugin sẽ tự động:
131
139
 
132
140
  - Tự động sử dụng refresh token để lấy token mới
133
141
  - Thử lại tối đa 2 lần đối với các lỗi tạm thời (timeout, lỗi mạng)
134
- - Xóa token yêu cầu đăng nhập lại nếu nhận lỗi 401/403
142
+ - Nếu refresh gặp `401/403`, plugin đánh dấu account hiện tại là `auth_invalid` tự chuyển sang account khỏe tiếp theo nếu
143
+ - Nếu không còn account khỏe, plugin sẽ yêu cầu đăng nhập lại (`opencode auth login`)
135
144
 
136
145
  ## Quản lý xác thực
137
146
 
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * @license MIT with Usage Disclaimer (see LICENSE file)
13
13
  * @repository https://github.com/TVD-00/opencode-qwen-cli-auth
14
- * @version 2.3.7
14
+ * @version 2.3.8
15
15
  */
16
16
 
17
17
  import { randomUUID } from "node:crypto";
@@ -40,7 +40,7 @@ const CLI_FALLBACK_MAX_BUFFER_CHARS = 1024 * 1024;
40
40
  /** Enable CLI fallback feature via environment variable */
41
41
  const ENABLE_CLI_FALLBACK = process.env.OPENCODE_QWEN_ENABLE_CLI_FALLBACK === "1";
42
42
  /** User agent string for plugin identification */
43
- const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.3.4";
43
+ const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.3.8";
44
44
  /** Output token limits per model for DashScope OAuth */
45
45
  const DASH_SCOPE_OUTPUT_LIMITS = {
46
46
  "coder-model": 65536,
@@ -104,6 +104,11 @@ const CLIENT_ONLY_BODY_FIELDS = new Set([
104
104
  "options",
105
105
  "debug",
106
106
  ]);
107
+ const REASONING_CONTROL_FIELDS = new Set([
108
+ "reasoning",
109
+ "reasoningEffort",
110
+ "reasoning_effort",
111
+ ]);
107
112
  function resolveQwenCliCommand() {
108
113
  const fromEnv = process.env.QWEN_CLI_PATH;
109
114
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
@@ -394,6 +399,12 @@ function sanitizeOutgoingPayload(payload) {
394
399
  delete sanitized.stream_options;
395
400
  changed = true;
396
401
  }
402
+ for (const field of REASONING_CONTROL_FIELDS) {
403
+ if (field in sanitized) {
404
+ delete sanitized[field];
405
+ changed = true;
406
+ }
407
+ }
397
408
  // Cap max_tokens fields
398
409
  if (typeof sanitized.max_tokens === "number" && sanitized.max_tokens > CHAT_MAX_TOKENS_CAP) {
399
410
  sanitized.max_tokens = CHAT_MAX_TOKENS_CAP;
@@ -1184,7 +1195,10 @@ async function getValidAccessToken(getAuth) {
1184
1195
  accessToken = refreshResult.access;
1185
1196
  resourceUrl = refreshResult.resourceUrl;
1186
1197
  saveToken(refreshResult);
1187
- await upsertOAuthAccount(refreshResult, { setActive: false });
1198
+ await upsertOAuthAccount(refreshResult, {
1199
+ setActive: false,
1200
+ accountId: activeOAuthAccount?.accountId,
1201
+ });
1188
1202
  }
1189
1203
  else {
1190
1204
  if (LOGGING_ENABLED) {
@@ -1210,7 +1224,10 @@ async function getValidAccessToken(getAuth) {
1210
1224
  resourceUrl,
1211
1225
  };
1212
1226
  saveToken(sdkToken);
1213
- await upsertOAuthAccount(sdkToken, { setActive: false });
1227
+ await upsertOAuthAccount(sdkToken, {
1228
+ setActive: false,
1229
+ accountId: activeOAuthAccount?.accountId,
1230
+ });
1214
1231
  }
1215
1232
  catch (e) {
1216
1233
  logWarn("Failed to bootstrap .qwen token from SDK auth state:", e);
@@ -1380,6 +1397,114 @@ export const QwenAuthPlugin = async (_input) => {
1380
1397
  };
1381
1398
  },
1382
1399
  },
1400
+ {
1401
+ label: "Add another Qwen account (multi-account switch)",
1402
+ type: "oauth",
1403
+ /**
1404
+ * Them account Qwen phu de auto-switch khi account chinh het quota.
1405
+ * Luon tao account moi (forceNew), khong ghi de account cu.
1406
+ * Account moi khong duoc dat active ngay (setActive: false).
1407
+ */
1408
+ authorize: async () => {
1409
+ const pkce = await createPKCE();
1410
+ const deviceAuth = await requestDeviceCode(pkce);
1411
+ if (!deviceAuth) {
1412
+ throw new Error("Failed to request device code");
1413
+ }
1414
+ console.log(`\n[Add Account] Please visit: ${deviceAuth.verification_uri}`);
1415
+ console.log(`[Add Account] Enter code: ${deviceAuth.user_code}\n`);
1416
+ const verificationUrl = deviceAuth.verification_uri_complete || deviceAuth.verification_uri;
1417
+ return {
1418
+ url: verificationUrl,
1419
+ method: "auto",
1420
+ instructions: "Login with a DIFFERENT Qwen account to add it as backup for auto-switch.",
1421
+ callback: async () => {
1422
+ let pollInterval = (deviceAuth.interval || 5) * 1000;
1423
+ const POLLING_MARGIN_MS = 3000;
1424
+ const maxInterval = DEVICE_FLOW.MAX_POLL_INTERVAL;
1425
+ const startTime = Date.now();
1426
+ const expiresIn = deviceAuth.expires_in * 1000;
1427
+ let consecutivePollFailures = 0;
1428
+ while (Date.now() - startTime < expiresIn) {
1429
+ await new Promise(resolve => setTimeout(resolve, pollInterval + POLLING_MARGIN_MS));
1430
+ const result = await pollForToken(deviceAuth.device_code, pkce.verifier);
1431
+ if (result.type === "success") {
1432
+ // Luu vao legacy token file de loader co the doc
1433
+ saveToken(result);
1434
+ // forceNew: luon tao account moi, khong match account cu
1435
+ // setActive: false - giu account hien tai, chi them du phong
1436
+ const savedAccount = await upsertOAuthAccount(result, {
1437
+ setActive: false,
1438
+ forceNew: true,
1439
+ });
1440
+ if (LOGGING_ENABLED) {
1441
+ logInfo("Added new backup Qwen account", {
1442
+ accountId: savedAccount?.accountId,
1443
+ totalAccounts: savedAccount?.totalAccountCount,
1444
+ healthyAccounts: savedAccount?.healthyAccountCount,
1445
+ });
1446
+ }
1447
+ console.log(`[Add Account] Success! Account added. Total accounts: ${savedAccount?.totalAccountCount || "?"}`);
1448
+ // Khoi phuc legacy token file (oauth_creds.json) ve active account
1449
+ // de loader doc dung token cua account chinh, khong dung token account moi
1450
+ try {
1451
+ const activeAcct = await getActiveOAuthAccount({ allowExhausted: true });
1452
+ if (activeAcct?.accessToken) {
1453
+ // getActiveOAuthAccount da tu dong sync vao oauth_creds.json
1454
+ // nen khong can goi saveToken thu cong
1455
+ }
1456
+ } catch (_restoreError) {
1457
+ // Khong anh huong chuc nang chinh
1458
+ }
1459
+ return {
1460
+ type: "success",
1461
+ access: result.access,
1462
+ refresh: result.refresh,
1463
+ expires: result.expires,
1464
+ };
1465
+ }
1466
+ if (result.type === "slow_down") {
1467
+ consecutivePollFailures = 0;
1468
+ pollInterval = Math.min(pollInterval + 5000, maxInterval);
1469
+ continue;
1470
+ }
1471
+ if (result.type === "pending") {
1472
+ consecutivePollFailures = 0;
1473
+ continue;
1474
+ }
1475
+ if (result.type === "failed") {
1476
+ if (result.fatal) {
1477
+ logError("OAuth token polling failed with fatal error (add-account)", {
1478
+ status: result.status,
1479
+ error: result.error,
1480
+ description: result.description,
1481
+ });
1482
+ return { type: "failed" };
1483
+ }
1484
+ consecutivePollFailures += 1;
1485
+ logWarn(`OAuth token polling failed (add-account) (${consecutivePollFailures}/${MAX_CONSECUTIVE_POLL_FAILURES})`);
1486
+ if (consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
1487
+ console.error("[qwen-oauth-plugin] OAuth token polling failed repeatedly (add-account)");
1488
+ return { type: "failed" };
1489
+ }
1490
+ continue;
1491
+ }
1492
+ if (result.type === "denied") {
1493
+ console.error("[qwen-oauth-plugin] Device authorization was denied (add-account)");
1494
+ return { type: "failed" };
1495
+ }
1496
+ if (result.type === "expired") {
1497
+ console.error("[qwen-oauth-plugin] Device authorization code expired (add-account)");
1498
+ return { type: "failed" };
1499
+ }
1500
+ return { type: "failed" };
1501
+ }
1502
+ console.error("[qwen-oauth-plugin] Device authorization timed out (add-account)");
1503
+ return { type: "failed" };
1504
+ },
1505
+ };
1506
+ },
1507
+ },
1383
1508
  ],
1384
1509
  },
1385
1510
  /**
@@ -1401,13 +1526,16 @@ export const QwenAuthPlugin = async (_input) => {
1401
1526
  "coder-model": {
1402
1527
  id: "coder-model",
1403
1528
  name: "Qwen 3.5 Plus",
1404
- // Qwen does not support reasoning_effort from OpenCode UI
1405
- // Thinking is always enabled by default on server side (qwen3.5-plus)
1406
1529
  attachment: false,
1407
- reasoning: false,
1530
+ reasoning: true,
1408
1531
  limit: { context: 1048576, output: CHAT_MAX_TOKENS_CAP },
1409
1532
  cost: { input: 0, output: 0 },
1410
1533
  modalities: { input: ["text"], output: ["text"] },
1534
+ variants: {
1535
+ low: { disabled: true },
1536
+ medium: { disabled: true },
1537
+ high: { disabled: true },
1538
+ },
1411
1539
  },
1412
1540
  "vision-model": {
1413
1541
  id: "vision-model",
@@ -45,6 +45,7 @@ export declare function upsertOAuthAccount(tokenResult: TokenResult, options?: {
45
45
  accountId?: string;
46
46
  accountKey?: string;
47
47
  setActive?: boolean;
48
+ forceNew?: boolean;
48
49
  }): Promise<{
49
50
  accountId: string;
50
51
  accessToken: string;
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { generatePKCE } from "@openauthjs/openauth/pkce";
9
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, statSync } from "fs";
10
+ import { dirname } from "path";
10
11
  import { QWEN_OAUTH, DEFAULT_QWEN_BASE_URL, TOKEN_REFRESH_BUFFER_MS, VERIFICATION_URI } from "../constants.js";
11
12
  import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath, getAccountsPath, getAccountsLockPath } from "../config.js";
12
13
  import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
@@ -307,11 +308,15 @@ function buildAccountEntry(tokenData, accountId, accountKey) {
307
308
  }
308
309
 
309
310
  function writeAccountsStoreData(store) {
311
+ const accountsPath = getAccountsPath();
312
+ const accountsDir = dirname(accountsPath);
310
313
  const qwenDir = getQwenDir();
311
314
  if (!existsSync(qwenDir)) {
312
315
  mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
313
316
  }
314
- const accountsPath = getAccountsPath();
317
+ if (!existsSync(accountsDir)) {
318
+ mkdirSync(accountsDir, { recursive: true, mode: 0o700 });
319
+ }
315
320
  const tempPath = `${accountsPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
316
321
  const payload = {
317
322
  version: ACCOUNT_STORE_VERSION,
@@ -583,9 +588,9 @@ function releaseTokenLock(lockPath) {
583
588
 
584
589
  async function acquireAccountsLock() {
585
590
  const lockPath = getAccountsLockPath();
586
- const qwenDir = getQwenDir();
587
- if (!existsSync(qwenDir)) {
588
- mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
591
+ const lockDir = dirname(lockPath);
592
+ if (!existsSync(lockDir)) {
593
+ mkdirSync(lockDir, { recursive: true, mode: 0o700 });
589
594
  }
590
595
  const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
591
596
  let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
@@ -1051,14 +1056,17 @@ export async function upsertOAuthAccount(tokenResult, options = {}) {
1051
1056
  await withAccountsStoreLock((store) => {
1052
1057
  const now = Date.now();
1053
1058
  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);
1059
+ // forceNew: bo qua match, luon tao account moi (dung cho "Add another account")
1060
+ if (!options.forceNew) {
1061
+ if (typeof options.accountId === "string" && options.accountId.length > 0) {
1062
+ index = store.accounts.findIndex(account => account.id === options.accountId);
1063
+ }
1064
+ if (index < 0 && accountKey) {
1065
+ index = store.accounts.findIndex(account => account.accountKey === accountKey);
1066
+ }
1067
+ if (index < 0) {
1068
+ index = store.accounts.findIndex(account => account.token?.refresh_token === tokenData.refresh_token);
1069
+ }
1062
1070
  }
1063
1071
  if (index < 0) {
1064
1072
  const newId = typeof options.accountId === "string" && options.accountId.length > 0
@@ -1096,89 +1104,140 @@ export async function upsertOAuthAccount(tokenResult, options = {}) {
1096
1104
 
1097
1105
  export async function getActiveOAuthAccount(options = {}) {
1098
1106
  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;
1107
+ const preferredAccountId = typeof options.preferredAccountId === "string" && options.preferredAccountId.length > 0
1108
+ ? options.preferredAccountId
1109
+ : null;
1110
+ const attemptedAuthRejected = new Set();
1111
+ for (; ;) {
1112
+ const lockPath = await acquireAccountsLock();
1113
+ let selected = null;
1114
+ let dirty = false;
1115
+ try {
1116
+ const store = loadAccountsStoreData();
1117
+ const now = Date.now();
1118
+ if (store.accounts.length === 0) {
1119
+ return null;
1120
+ }
1121
+ if (attemptedAuthRejected.size === 0 && preferredAccountId) {
1122
+ const exists = store.accounts.some(account => account.id === preferredAccountId);
1123
+ if (exists && store.activeAccountId !== preferredAccountId) {
1124
+ store.activeAccountId = preferredAccountId;
1125
+ dirty = true;
1126
+ }
1127
+ }
1128
+ let active = store.accounts.find(account => account.id === store.activeAccountId);
1129
+ if (!active) {
1130
+ active = store.accounts[0];
1131
+ store.activeAccountId = active.id;
1112
1132
  dirty = true;
1113
1133
  }
1134
+ const activeHealthy = !(typeof active.exhaustedUntil === "number" && active.exhaustedUntil > now);
1135
+ if (!activeHealthy && !options.allowExhausted) {
1136
+ const replacement = pickNextHealthyAccount(store, new Set(), now);
1137
+ if (!replacement) {
1138
+ return null;
1139
+ }
1140
+ if (store.activeAccountId !== replacement.id) {
1141
+ store.activeAccountId = replacement.id;
1142
+ dirty = true;
1143
+ }
1144
+ active = replacement;
1145
+ }
1146
+ const healthyCount = countHealthyAccounts(store, now);
1147
+ selected = {
1148
+ account: { ...active },
1149
+ healthyCount,
1150
+ totalCount: store.accounts.length,
1151
+ };
1152
+ if (dirty) {
1153
+ writeAccountsStoreData(store);
1154
+ }
1114
1155
  }
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;
1156
+ finally {
1157
+ releaseAccountsLock(lockPath);
1120
1158
  }
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;
1159
+ if (!selected) {
1160
+ return null;
1161
+ }
1162
+ if (options.requireHealthy && selected.account.exhaustedUntil > Date.now()) {
1163
+ return null;
1164
+ }
1165
+ try {
1166
+ syncAccountToLegacyTokenFile(selected.account);
1167
+ }
1168
+ catch (error) {
1169
+ logWarn("Failed to sync active account token to oauth_creds.json", error);
1170
+ return null;
1171
+ }
1172
+ const valid = await getValidTokenDetailed({ clearOnFailure: false });
1173
+ if (valid.type === "success") {
1174
+ const latest = loadStoredToken();
1175
+ if (latest) {
1176
+ try {
1177
+ await withAccountsStoreLock((store) => {
1178
+ const target = store.accounts.find(account => account.id === selected.account.id);
1179
+ if (!target) {
1180
+ return store;
1181
+ }
1182
+ target.token = latest;
1183
+ target.resource_url = latest.resource_url;
1184
+ target.updatedAt = Date.now();
1185
+ return store;
1186
+ });
1187
+ }
1188
+ catch (error) {
1189
+ logWarn("Failed to update account token from refreshed legacy token", error);
1190
+ }
1130
1191
  }
1131
- active = replacement;
1192
+ return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
1132
1193
  }
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);
1194
+ if (valid.type !== "auth_rejected") {
1195
+ return null;
1141
1196
  }
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) {
1197
+ attemptedAuthRejected.add(selected.account.id);
1198
+ if (attemptedAuthRejected.size >= selected.totalCount) {
1199
+ if (LOGGING_ENABLED) {
1200
+ logWarn("All OAuth accounts rejected with auth_invalid, re-authentication required", {
1201
+ attempted: attemptedAuthRejected.size,
1202
+ total: selected.totalCount,
1203
+ });
1204
+ }
1205
+ return null;
1206
+ }
1207
+ let switchedToHealthy = false;
1165
1208
  try {
1166
1209
  await withAccountsStoreLock((store) => {
1210
+ const now = Date.now();
1167
1211
  const target = store.accounts.find(account => account.id === selected.account.id);
1168
1212
  if (!target) {
1169
1213
  return store;
1170
1214
  }
1171
- target.token = latest;
1172
- target.resource_url = latest.resource_url;
1173
- target.updatedAt = Date.now();
1215
+ target.exhaustedUntil = now + getQuotaCooldownMs();
1216
+ target.lastErrorCode = "auth_invalid";
1217
+ target.updatedAt = now;
1218
+ const next = pickNextHealthyAccount(store, attemptedAuthRejected, now);
1219
+ if (next) {
1220
+ store.activeAccountId = next.id;
1221
+ switchedToHealthy = true;
1222
+ }
1174
1223
  return store;
1175
1224
  });
1176
1225
  }
1177
1226
  catch (error) {
1178
- logWarn("Failed to update account token from refreshed legacy token", error);
1227
+ logWarn("Failed to switch OAuth account after auth_invalid", error);
1228
+ return null;
1229
+ }
1230
+ if (!switchedToHealthy) {
1231
+ if (LOGGING_ENABLED) {
1232
+ logWarn("No healthy OAuth account available after auth_invalid", {
1233
+ accountID: selected.account.id,
1234
+ attempted: attemptedAuthRejected.size,
1235
+ total: selected.totalCount,
1236
+ });
1237
+ }
1238
+ return null;
1179
1239
  }
1180
1240
  }
1181
- return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
1182
1241
  }
1183
1242
 
1184
1243
  export async function markOAuthAccountQuotaExhausted(accountId, errorCode = "insufficient_quota") {
@@ -1248,18 +1307,15 @@ export function isTokenExpired(expiresAt) {
1248
1307
  return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
1249
1308
  }
1250
1309
 
1251
- /**
1252
- * Gets valid access token, refreshing if expired
1253
- * @returns {Promise<{ accessToken: string, resourceUrl?: string }|null>} Valid token or null if unavailable
1254
- */
1255
- export async function getValidToken() {
1310
+ async function getValidTokenDetailed(options = {}) {
1311
+ const clearOnFailure = options.clearOnFailure === true;
1256
1312
  const stored = loadStoredToken();
1257
1313
  if (!stored) {
1258
- return null;
1314
+ return { type: "missing" };
1259
1315
  }
1260
- // Return cached token if still valid
1261
1316
  if (!isTokenExpired(stored.expiry_date)) {
1262
1317
  return {
1318
+ type: "success",
1263
1319
  accessToken: stored.access_token,
1264
1320
  resourceUrl: stored.resource_url,
1265
1321
  };
@@ -1267,16 +1323,48 @@ export async function getValidToken() {
1267
1323
  if (LOGGING_ENABLED) {
1268
1324
  logInfo("Token expired, refreshing...");
1269
1325
  }
1270
- // Token expired, try to refresh
1271
1326
  const refreshResult = await refreshAccessToken(stored.refresh_token);
1272
- if (refreshResult.type !== "success") {
1273
- logError("Token refresh failed, re-authentication required");
1327
+ if (refreshResult.type === "success") {
1328
+ return {
1329
+ type: "success",
1330
+ accessToken: refreshResult.access,
1331
+ resourceUrl: refreshResult.resourceUrl,
1332
+ };
1333
+ }
1334
+ const status = typeof refreshResult.status === "number" ? refreshResult.status : undefined;
1335
+ const isAuthRejected = status === 401 ||
1336
+ status === 403 ||
1337
+ refreshResult.error === "refresh_token_rejected";
1338
+ if (clearOnFailure) {
1274
1339
  clearStoredToken();
1340
+ }
1341
+ if (isAuthRejected) {
1342
+ return {
1343
+ type: "auth_rejected",
1344
+ status,
1345
+ error: refreshResult.error || "refresh_token_rejected",
1346
+ };
1347
+ }
1348
+ return {
1349
+ type: "transient_or_unknown",
1350
+ status,
1351
+ error: refreshResult.error || "refresh_failed",
1352
+ };
1353
+ }
1354
+
1355
+ /**
1356
+ * Gets valid access token, refreshing if expired
1357
+ * @returns {Promise<{ accessToken: string, resourceUrl?: string }|null>} Valid token or null if unavailable
1358
+ */
1359
+ export async function getValidToken() {
1360
+ const result = await getValidTokenDetailed({ clearOnFailure: true });
1361
+ if (result.type !== "success") {
1362
+ logError("Token refresh failed, re-authentication required");
1275
1363
  return null;
1276
1364
  }
1277
1365
  return {
1278
- accessToken: refreshResult.access,
1279
- resourceUrl: refreshResult.resourceUrl,
1366
+ accessToken: result.accessToken,
1367
+ resourceUrl: result.resourceUrl,
1280
1368
  };
1281
1369
  }
1282
1370
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-qwen-cli-auth",
3
- "version": "2.3.7",
3
+ "version": "2.3.9",
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",