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 +15 -3
- package/README.vi.md +15 -3
- package/dist/index.js +149 -19
- package/dist/lib/auth/auth.d.ts +51 -1
- package/dist/lib/auth/auth.js +546 -1
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.js +65 -1
- package/package.json +1 -1
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. **
|
|
117
|
-
2. **
|
|
118
|
-
3. **
|
|
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.
|
|
117
|
-
2.
|
|
118
|
-
3. **
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1091
|
-
if (!accessToken) return null;
|
|
1092
|
-
|
|
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",
|
package/dist/lib/auth/auth.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/auth/auth.js
CHANGED
|
@@ -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
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/lib/config.js
CHANGED
|
@@ -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