opencode-qwen-cli-auth 2.3.6 → 2.3.8

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.6
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);
@@ -1400,14 +1417,17 @@ export const QwenAuthPlugin = async (_input) => {
1400
1417
  models: {
1401
1418
  "coder-model": {
1402
1419
  id: "coder-model",
1403
- 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)
1420
+ name: "Qwen 3.5 Plus",
1406
1421
  attachment: false,
1407
- reasoning: false,
1422
+ reasoning: true,
1408
1423
  limit: { context: 1048576, output: CHAT_MAX_TOKENS_CAP },
1409
1424
  cost: { input: 0, output: 0 },
1410
1425
  modalities: { input: ["text"], output: ["text"] },
1426
+ variants: {
1427
+ low: { disabled: true },
1428
+ medium: { disabled: true },
1429
+ high: { disabled: true },
1430
+ },
1411
1431
  },
1412
1432
  "vision-model": {
1413
1433
  id: "vision-model",
@@ -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;
@@ -1096,89 +1101,140 @@ export async function upsertOAuthAccount(tokenResult, options = {}) {
1096
1101
 
1097
1102
  export async function getActiveOAuthAccount(options = {}) {
1098
1103
  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;
1104
+ const preferredAccountId = typeof options.preferredAccountId === "string" && options.preferredAccountId.length > 0
1105
+ ? options.preferredAccountId
1106
+ : null;
1107
+ const attemptedAuthRejected = new Set();
1108
+ for (;;) {
1109
+ const lockPath = await acquireAccountsLock();
1110
+ let selected = null;
1111
+ let dirty = false;
1112
+ try {
1113
+ const store = loadAccountsStoreData();
1114
+ const now = Date.now();
1115
+ if (store.accounts.length === 0) {
1116
+ return null;
1117
+ }
1118
+ if (attemptedAuthRejected.size === 0 && preferredAccountId) {
1119
+ const exists = store.accounts.some(account => account.id === preferredAccountId);
1120
+ if (exists && store.activeAccountId !== preferredAccountId) {
1121
+ store.activeAccountId = preferredAccountId;
1122
+ dirty = true;
1123
+ }
1124
+ }
1125
+ let active = store.accounts.find(account => account.id === store.activeAccountId);
1126
+ if (!active) {
1127
+ active = store.accounts[0];
1128
+ store.activeAccountId = active.id;
1112
1129
  dirty = true;
1113
1130
  }
1131
+ const activeHealthy = !(typeof active.exhaustedUntil === "number" && active.exhaustedUntil > now);
1132
+ if (!activeHealthy && !options.allowExhausted) {
1133
+ const replacement = pickNextHealthyAccount(store, new Set(), now);
1134
+ if (!replacement) {
1135
+ return null;
1136
+ }
1137
+ if (store.activeAccountId !== replacement.id) {
1138
+ store.activeAccountId = replacement.id;
1139
+ dirty = true;
1140
+ }
1141
+ active = replacement;
1142
+ }
1143
+ const healthyCount = countHealthyAccounts(store, now);
1144
+ selected = {
1145
+ account: { ...active },
1146
+ healthyCount,
1147
+ totalCount: store.accounts.length,
1148
+ };
1149
+ if (dirty) {
1150
+ writeAccountsStoreData(store);
1151
+ }
1114
1152
  }
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;
1153
+ finally {
1154
+ releaseAccountsLock(lockPath);
1120
1155
  }
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;
1156
+ if (!selected) {
1157
+ return null;
1158
+ }
1159
+ if (options.requireHealthy && selected.account.exhaustedUntil > Date.now()) {
1160
+ return null;
1161
+ }
1162
+ try {
1163
+ syncAccountToLegacyTokenFile(selected.account);
1164
+ }
1165
+ catch (error) {
1166
+ logWarn("Failed to sync active account token to oauth_creds.json", error);
1167
+ return null;
1168
+ }
1169
+ const valid = await getValidTokenDetailed({ clearOnFailure: false });
1170
+ if (valid.type === "success") {
1171
+ const latest = loadStoredToken();
1172
+ if (latest) {
1173
+ try {
1174
+ await withAccountsStoreLock((store) => {
1175
+ const target = store.accounts.find(account => account.id === selected.account.id);
1176
+ if (!target) {
1177
+ return store;
1178
+ }
1179
+ target.token = latest;
1180
+ target.resource_url = latest.resource_url;
1181
+ target.updatedAt = Date.now();
1182
+ return store;
1183
+ });
1184
+ }
1185
+ catch (error) {
1186
+ logWarn("Failed to update account token from refreshed legacy token", error);
1187
+ }
1130
1188
  }
1131
- active = replacement;
1189
+ return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
1132
1190
  }
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);
1191
+ if (valid.type !== "auth_rejected") {
1192
+ return null;
1141
1193
  }
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) {
1194
+ attemptedAuthRejected.add(selected.account.id);
1195
+ if (attemptedAuthRejected.size >= selected.totalCount) {
1196
+ if (LOGGING_ENABLED) {
1197
+ logWarn("All OAuth accounts rejected with auth_invalid, re-authentication required", {
1198
+ attempted: attemptedAuthRejected.size,
1199
+ total: selected.totalCount,
1200
+ });
1201
+ }
1202
+ return null;
1203
+ }
1204
+ let switchedToHealthy = false;
1165
1205
  try {
1166
1206
  await withAccountsStoreLock((store) => {
1207
+ const now = Date.now();
1167
1208
  const target = store.accounts.find(account => account.id === selected.account.id);
1168
1209
  if (!target) {
1169
1210
  return store;
1170
1211
  }
1171
- target.token = latest;
1172
- target.resource_url = latest.resource_url;
1173
- target.updatedAt = Date.now();
1212
+ target.exhaustedUntil = now + getQuotaCooldownMs();
1213
+ target.lastErrorCode = "auth_invalid";
1214
+ target.updatedAt = now;
1215
+ const next = pickNextHealthyAccount(store, attemptedAuthRejected, now);
1216
+ if (next) {
1217
+ store.activeAccountId = next.id;
1218
+ switchedToHealthy = true;
1219
+ }
1174
1220
  return store;
1175
1221
  });
1176
1222
  }
1177
1223
  catch (error) {
1178
- logWarn("Failed to update account token from refreshed legacy token", error);
1224
+ logWarn("Failed to switch OAuth account after auth_invalid", error);
1225
+ return null;
1226
+ }
1227
+ if (!switchedToHealthy) {
1228
+ if (LOGGING_ENABLED) {
1229
+ logWarn("No healthy OAuth account available after auth_invalid", {
1230
+ accountID: selected.account.id,
1231
+ attempted: attemptedAuthRejected.size,
1232
+ total: selected.totalCount,
1233
+ });
1234
+ }
1235
+ return null;
1179
1236
  }
1180
1237
  }
1181
- return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
1182
1238
  }
1183
1239
 
1184
1240
  export async function markOAuthAccountQuotaExhausted(accountId, errorCode = "insufficient_quota") {
@@ -1248,18 +1304,15 @@ export function isTokenExpired(expiresAt) {
1248
1304
  return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
1249
1305
  }
1250
1306
 
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() {
1307
+ async function getValidTokenDetailed(options = {}) {
1308
+ const clearOnFailure = options.clearOnFailure === true;
1256
1309
  const stored = loadStoredToken();
1257
1310
  if (!stored) {
1258
- return null;
1311
+ return { type: "missing" };
1259
1312
  }
1260
- // Return cached token if still valid
1261
1313
  if (!isTokenExpired(stored.expiry_date)) {
1262
1314
  return {
1315
+ type: "success",
1263
1316
  accessToken: stored.access_token,
1264
1317
  resourceUrl: stored.resource_url,
1265
1318
  };
@@ -1267,16 +1320,48 @@ export async function getValidToken() {
1267
1320
  if (LOGGING_ENABLED) {
1268
1321
  logInfo("Token expired, refreshing...");
1269
1322
  }
1270
- // Token expired, try to refresh
1271
1323
  const refreshResult = await refreshAccessToken(stored.refresh_token);
1272
- if (refreshResult.type !== "success") {
1273
- logError("Token refresh failed, re-authentication required");
1324
+ if (refreshResult.type === "success") {
1325
+ return {
1326
+ type: "success",
1327
+ accessToken: refreshResult.access,
1328
+ resourceUrl: refreshResult.resourceUrl,
1329
+ };
1330
+ }
1331
+ const status = typeof refreshResult.status === "number" ? refreshResult.status : undefined;
1332
+ const isAuthRejected = status === 401 ||
1333
+ status === 403 ||
1334
+ refreshResult.error === "refresh_token_rejected";
1335
+ if (clearOnFailure) {
1274
1336
  clearStoredToken();
1337
+ }
1338
+ if (isAuthRejected) {
1339
+ return {
1340
+ type: "auth_rejected",
1341
+ status,
1342
+ error: refreshResult.error || "refresh_token_rejected",
1343
+ };
1344
+ }
1345
+ return {
1346
+ type: "transient_or_unknown",
1347
+ status,
1348
+ error: refreshResult.error || "refresh_failed",
1349
+ };
1350
+ }
1351
+
1352
+ /**
1353
+ * Gets valid access token, refreshing if expired
1354
+ * @returns {Promise<{ accessToken: string, resourceUrl?: string }|null>} Valid token or null if unavailable
1355
+ */
1356
+ export async function getValidToken() {
1357
+ const result = await getValidTokenDetailed({ clearOnFailure: true });
1358
+ if (result.type !== "success") {
1359
+ logError("Token refresh failed, re-authentication required");
1275
1360
  return null;
1276
1361
  }
1277
1362
  return {
1278
- accessToken: refreshResult.access,
1279
- resourceUrl: refreshResult.resourceUrl,
1363
+ accessToken: result.accessToken,
1364
+ resourceUrl: result.resourceUrl,
1280
1365
  };
1281
1366
  }
1282
1367
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-qwen-cli-auth",
3
- "version": "2.3.6",
3
+ "version": "2.3.8",
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",