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 +10 -1
- package/README.vi.md +10 -1
- package/dist/index.js +135 -7
- package/dist/lib/auth/auth.d.ts +1 -0
- package/dist/lib/auth/auth.js +176 -88
- package/package.json +1 -1
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
|
-
-
|
|
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
|
-
-
|
|
142
|
+
- Nếu refresh gặp `401/403`, plugin đánh dấu account hiện tại là `auth_invalid` và tự chuyển sang account khỏe tiếp theo nếu có
|
|
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.
|
|
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.
|
|
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, {
|
|
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, {
|
|
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:
|
|
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",
|
package/dist/lib/auth/auth.d.ts
CHANGED
package/dist/lib/auth/auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
587
|
-
if (!existsSync(
|
|
588
|
-
mkdirSync(
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
const
|
|
1110
|
-
if (
|
|
1111
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
active = store.accounts[0];
|
|
1118
|
-
store.activeAccountId = active.id;
|
|
1119
|
-
dirty = true;
|
|
1156
|
+
finally {
|
|
1157
|
+
releaseAccountsLock(lockPath);
|
|
1120
1158
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
1192
|
+
return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
|
|
1132
1193
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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.
|
|
1172
|
-
target.
|
|
1173
|
-
target.updatedAt =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1273
|
-
|
|
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:
|
|
1279
|
-
resourceUrl:
|
|
1366
|
+
accessToken: result.accessToken,
|
|
1367
|
+
resourceUrl: result.resourceUrl,
|
|
1280
1368
|
};
|
|
1281
1369
|
}
|
|
1282
1370
|
|
package/package.json
CHANGED