opencode-qwen-cli-auth 2.3.0 → 2.3.3
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 +204 -55
- 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: `86400000` (24h) |
|
|
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: `86400000` (24 giờ) |
|
|
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,12 +17,12 @@
|
|
|
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
|
|
|
24
24
|
/** Request timeout for chat completions in milliseconds */
|
|
25
|
-
const CHAT_REQUEST_TIMEOUT_MS =
|
|
25
|
+
const CHAT_REQUEST_TIMEOUT_MS = 120000;
|
|
26
26
|
/** Maximum number of retry attempts for failed requests */
|
|
27
27
|
const CHAT_MAX_RETRIES = 3;
|
|
28
28
|
/** Output token cap for coder-model (64K tokens) */
|
|
@@ -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
|
|
@@ -357,24 +415,28 @@ function sanitizeOutgoingPayload(payload) {
|
|
|
357
415
|
function createQuotaDegradedPayload(payload) {
|
|
358
416
|
const degraded = { ...payload };
|
|
359
417
|
let changed = false;
|
|
360
|
-
// Remove tool-related fields
|
|
361
|
-
if ("tools" in degraded) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
if ("tool_choice" in degraded) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
if ("parallel_tool_calls" in degraded) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
418
|
+
// Remove tool-related fields (skip removing tools so Agents don't break)
|
|
419
|
+
// if ("tools" in degraded) {
|
|
420
|
+
// delete degraded.tools;
|
|
421
|
+
// changed = true;
|
|
422
|
+
// }
|
|
423
|
+
// if ("tool_choice" in degraded) {
|
|
424
|
+
// delete degraded.tool_choice;
|
|
425
|
+
// changed = true;
|
|
426
|
+
// }
|
|
427
|
+
// if ("parallel_tool_calls" in degraded) {
|
|
428
|
+
// delete degraded.parallel_tool_calls;
|
|
429
|
+
// changed = true;
|
|
430
|
+
// }
|
|
373
431
|
// Disable streaming
|
|
374
432
|
if (degraded.stream !== false) {
|
|
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;
|
|
@@ -533,7 +595,7 @@ function createSseResponseChunk(data) {
|
|
|
533
595
|
* @param {boolean} streamMode - Whether to return streaming response
|
|
534
596
|
* @returns {Response} Formatted completion response
|
|
535
597
|
*/
|
|
536
|
-
function makeQwenCliCompletionResponse(model, content, context, streamMode) {
|
|
598
|
+
function makeQwenCliCompletionResponse(model, content, context, streamMode, abortSignal) {
|
|
537
599
|
if (LOGGING_ENABLED) {
|
|
538
600
|
logInfo("Qwen CLI fallback returned completion", {
|
|
539
601
|
request_id: context.requestId,
|
|
@@ -547,7 +609,7 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
|
|
|
547
609
|
const encoder = new TextEncoder();
|
|
548
610
|
const stream = new ReadableStream({
|
|
549
611
|
start(controller) {
|
|
550
|
-
// Send first chunk with content
|
|
612
|
+
// Send first chunk with empty content
|
|
551
613
|
controller.enqueue(encoder.encode(createSseResponseChunk({
|
|
552
614
|
id: completionId,
|
|
553
615
|
object: "chat.completion.chunk",
|
|
@@ -556,28 +618,51 @@ function makeQwenCliCompletionResponse(model, content, context, streamMode) {
|
|
|
556
618
|
choices: [
|
|
557
619
|
{
|
|
558
620
|
index: 0,
|
|
559
|
-
delta: { role: "assistant", content },
|
|
621
|
+
delta: { role: "assistant", content: "" },
|
|
560
622
|
finish_reason: null,
|
|
561
623
|
},
|
|
562
624
|
],
|
|
563
625
|
})));
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
{
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
626
|
+
|
|
627
|
+
const CHUNK_SIZE = 15;
|
|
628
|
+
const DELAY_MS = 20;
|
|
629
|
+
let position = 0;
|
|
630
|
+
|
|
631
|
+
function pushNextChunk() {
|
|
632
|
+
if (abortSignal?.aborted) {
|
|
633
|
+
try { controller.close(); } catch (e) { }
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (position >= content.length) {
|
|
638
|
+
// Send stop chunk
|
|
639
|
+
controller.enqueue(encoder.encode(createSseResponseChunk({
|
|
640
|
+
id: completionId,
|
|
641
|
+
object: "chat.completion.chunk",
|
|
642
|
+
created,
|
|
643
|
+
model,
|
|
644
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
645
|
+
})));
|
|
646
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
647
|
+
try { controller.close(); } catch (e) { }
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const nextSlice = content.slice(position, position + CHUNK_SIZE);
|
|
652
|
+
position += CHUNK_SIZE;
|
|
653
|
+
|
|
654
|
+
controller.enqueue(encoder.encode(createSseResponseChunk({
|
|
655
|
+
id: completionId,
|
|
656
|
+
object: "chat.completion.chunk",
|
|
657
|
+
created,
|
|
658
|
+
model,
|
|
659
|
+
choices: [{ index: 0, delta: { content: nextSlice }, finish_reason: null }],
|
|
660
|
+
})));
|
|
661
|
+
|
|
662
|
+
setTimeout(pushNextChunk, DELAY_MS);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
pushNextChunk();
|
|
581
666
|
},
|
|
582
667
|
});
|
|
583
668
|
return new Response(stream, {
|
|
@@ -639,6 +724,8 @@ async function runQwenCliFallback(payload, context, abortSignal) {
|
|
|
639
724
|
command: QWEN_CLI_COMMAND,
|
|
640
725
|
});
|
|
641
726
|
}
|
|
727
|
+
// Use secure spawn logic across ALL OS, allowing .cmd locally on Windows by injecting shell correctly.
|
|
728
|
+
const isShellRequired = requiresShellExecution(QWEN_CLI_COMMAND);
|
|
642
729
|
return await new Promise((resolve) => {
|
|
643
730
|
let settled = false;
|
|
644
731
|
let stdout = "";
|
|
@@ -646,7 +733,6 @@ async function runQwenCliFallback(payload, context, abortSignal) {
|
|
|
646
733
|
let timer = null;
|
|
647
734
|
let child = undefined;
|
|
648
735
|
let abortHandler = undefined;
|
|
649
|
-
const useShell = shouldUseShell(QWEN_CLI_COMMAND);
|
|
650
736
|
const finalize = (result) => {
|
|
651
737
|
if (settled) {
|
|
652
738
|
return;
|
|
@@ -669,7 +755,7 @@ async function runQwenCliFallback(payload, context, abortSignal) {
|
|
|
669
755
|
}
|
|
670
756
|
try {
|
|
671
757
|
child = spawn(QWEN_CLI_COMMAND, args, {
|
|
672
|
-
shell:
|
|
758
|
+
shell: isShellRequired,
|
|
673
759
|
windowsHide: true,
|
|
674
760
|
stdio: ["ignore", "pipe", "pipe"],
|
|
675
761
|
});
|
|
@@ -724,7 +810,7 @@ async function runQwenCliFallback(payload, context, abortSignal) {
|
|
|
724
810
|
if (content) {
|
|
725
811
|
finalize({
|
|
726
812
|
ok: true,
|
|
727
|
-
response: makeQwenCliCompletionResponse(model, content, context, streamMode),
|
|
813
|
+
response: makeQwenCliCompletionResponse(model, content, context, streamMode, abortSignal),
|
|
728
814
|
});
|
|
729
815
|
return;
|
|
730
816
|
}
|
|
@@ -843,7 +929,7 @@ function applyDashScopeHeaders(requestInit) {
|
|
|
843
929
|
*/
|
|
844
930
|
async function failFastFetch(input, init) {
|
|
845
931
|
const normalized = await normalizeFetchInvocation(input, init);
|
|
846
|
-
|
|
932
|
+
let requestInput = normalized.requestInput;
|
|
847
933
|
const requestInit = normalized.requestInit;
|
|
848
934
|
// Always inject DashScope OAuth headers at the fetch layer.
|
|
849
935
|
// This ensures compatibility across OpenCode versions.
|
|
@@ -869,12 +955,14 @@ async function failFastFetch(input, init) {
|
|
|
869
955
|
requestId: getHeaderValue(requestInit.headers, "x-request-id"),
|
|
870
956
|
sessionID,
|
|
871
957
|
modelID: typeof payload?.model === "string" ? payload.model : undefined,
|
|
958
|
+
accountID: ACTIVE_OAUTH_ACCOUNT_ID,
|
|
872
959
|
};
|
|
873
960
|
if (LOGGING_ENABLED) {
|
|
874
961
|
logInfo("Qwen request dispatch", {
|
|
875
962
|
request_id: context.requestId,
|
|
876
963
|
sessionID: context.sessionID,
|
|
877
964
|
modelID: context.modelID,
|
|
965
|
+
accountID: context.accountID,
|
|
878
966
|
max_tokens: typeof payload?.max_tokens === "number" ? payload.max_tokens : undefined,
|
|
879
967
|
max_completion_tokens: typeof payload?.max_completion_tokens === "number" ? payload.max_completion_tokens : undefined,
|
|
880
968
|
message_count: Array.isArray(payload?.messages) ? payload.messages.length : undefined,
|
|
@@ -890,15 +978,47 @@ async function failFastFetch(input, init) {
|
|
|
890
978
|
request_id: context.requestId,
|
|
891
979
|
sessionID: context.sessionID,
|
|
892
980
|
modelID: context.modelID,
|
|
981
|
+
accountID: context.accountID,
|
|
893
982
|
status: response.status,
|
|
894
983
|
attempt: retryAttempt + 1,
|
|
895
984
|
});
|
|
896
985
|
}
|
|
897
|
-
const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
|
|
986
|
+
const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
|
|
898
987
|
if (RETRYABLE_STATUS_CODES.includes(response.status)) {
|
|
899
988
|
if (response.status === 429) {
|
|
900
989
|
const firstBody = await response.text().catch(() => "");
|
|
901
990
|
if (payload && isInsufficientQuota(firstBody)) {
|
|
991
|
+
if (context.accountID) {
|
|
992
|
+
try {
|
|
993
|
+
await markOAuthAccountQuotaExhausted(context.accountID, "insufficient_quota");
|
|
994
|
+
const switched = await switchToNextHealthyOAuthAccount([context.accountID]);
|
|
995
|
+
if (switched?.accessToken) {
|
|
996
|
+
const rotatedInit = { ...requestInit };
|
|
997
|
+
requestInput = rewriteRequestBaseUrl(requestInput, switched.resourceUrl);
|
|
998
|
+
applyAuthorizationHeader(rotatedInit, switched.accessToken);
|
|
999
|
+
applyAuthorizationHeader(requestInit, switched.accessToken);
|
|
1000
|
+
context.accountID = switched.accountId;
|
|
1001
|
+
ACTIVE_OAUTH_ACCOUNT_ID = switched.accountId;
|
|
1002
|
+
if (LOGGING_ENABLED) {
|
|
1003
|
+
logInfo("Switched OAuth account after insufficient_quota", {
|
|
1004
|
+
request_id: context.requestId,
|
|
1005
|
+
sessionID: context.sessionID,
|
|
1006
|
+
modelID: context.modelID,
|
|
1007
|
+
accountID: context.accountID,
|
|
1008
|
+
healthyAccounts: switched.healthyAccountCount,
|
|
1009
|
+
totalAccounts: switched.totalAccountCount,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
response = await sendWithTimeout(requestInput, rotatedInit);
|
|
1013
|
+
if (retryAttempt < MAX_REQUEST_RETRIES) {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
catch (switchError) {
|
|
1019
|
+
logWarn("Failed to switch OAuth account after insufficient_quota", switchError);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
902
1022
|
const degradedPayload = createQuotaDegradedPayload(payload);
|
|
903
1023
|
if (degradedPayload) {
|
|
904
1024
|
const fallbackInit = { ...requestInit };
|
|
@@ -989,25 +1109,39 @@ const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];
|
|
|
989
1109
|
* Uses getAuth() from SDK instead of reading file directly.
|
|
990
1110
|
*
|
|
991
1111
|
* @param {Function} getAuth - Function to get auth state from SDK
|
|
992
|
-
* @returns {Promise<string|null>} Access token or null
|
|
1112
|
+
* @returns {Promise<{ accessToken: string, resourceUrl?: string, accountId?: string }|null>} Access token state or null
|
|
993
1113
|
*/
|
|
994
1114
|
async function getValidAccessToken(getAuth) {
|
|
1115
|
+
const activeOAuthAccount = await getActiveOAuthAccount({ allowExhausted: true });
|
|
1116
|
+
if (activeOAuthAccount?.accessToken) {
|
|
1117
|
+
return {
|
|
1118
|
+
accessToken: activeOAuthAccount.accessToken,
|
|
1119
|
+
resourceUrl: activeOAuthAccount.resourceUrl,
|
|
1120
|
+
accountId: activeOAuthAccount.accountId,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
995
1123
|
const diskToken = await getValidToken();
|
|
996
1124
|
if (diskToken?.accessToken) {
|
|
997
|
-
return
|
|
1125
|
+
return {
|
|
1126
|
+
accessToken: diskToken.accessToken,
|
|
1127
|
+
resourceUrl: diskToken.resourceUrl,
|
|
1128
|
+
};
|
|
998
1129
|
}
|
|
999
1130
|
const auth = await getAuth();
|
|
1000
1131
|
if (!auth || auth.type !== "oauth") {
|
|
1001
1132
|
return null;
|
|
1002
1133
|
}
|
|
1003
1134
|
let accessToken = auth.access;
|
|
1135
|
+
let resourceUrl = undefined;
|
|
1004
1136
|
// Refresh if expired (60 second buffer)
|
|
1005
1137
|
if (accessToken && auth.expires && Date.now() > auth.expires - 60000 && auth.refresh) {
|
|
1006
1138
|
try {
|
|
1007
1139
|
const refreshResult = await refreshAccessToken(auth.refresh);
|
|
1008
1140
|
if (refreshResult.type === "success") {
|
|
1009
1141
|
accessToken = refreshResult.access;
|
|
1142
|
+
resourceUrl = refreshResult.resourceUrl;
|
|
1010
1143
|
saveToken(refreshResult);
|
|
1144
|
+
await upsertOAuthAccount(refreshResult, { setActive: false });
|
|
1011
1145
|
}
|
|
1012
1146
|
else {
|
|
1013
1147
|
if (LOGGING_ENABLED) {
|
|
@@ -1025,18 +1159,27 @@ async function getValidAccessToken(getAuth) {
|
|
|
1025
1159
|
}
|
|
1026
1160
|
if (auth.access && auth.refresh) {
|
|
1027
1161
|
try {
|
|
1028
|
-
|
|
1162
|
+
const sdkToken = {
|
|
1029
1163
|
type: "success",
|
|
1030
1164
|
access: accessToken || auth.access,
|
|
1031
1165
|
refresh: auth.refresh,
|
|
1032
1166
|
expires: typeof auth.expires === "number" ? auth.expires : Date.now() + 3600 * 1000,
|
|
1033
|
-
|
|
1167
|
+
resourceUrl,
|
|
1168
|
+
};
|
|
1169
|
+
saveToken(sdkToken);
|
|
1170
|
+
await upsertOAuthAccount(sdkToken, { setActive: false });
|
|
1034
1171
|
}
|
|
1035
1172
|
catch (e) {
|
|
1036
1173
|
logWarn("Failed to bootstrap .qwen token from SDK auth state:", e);
|
|
1037
1174
|
}
|
|
1038
1175
|
}
|
|
1039
|
-
|
|
1176
|
+
if (!accessToken) {
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
accessToken,
|
|
1181
|
+
resourceUrl,
|
|
1182
|
+
};
|
|
1040
1183
|
}
|
|
1041
1184
|
|
|
1042
1185
|
/**
|
|
@@ -1044,7 +1187,10 @@ async function getValidAccessToken(getAuth) {
|
|
|
1044
1187
|
* Falls back to DashScope compatible-mode if not available.
|
|
1045
1188
|
* @returns {string} DashScope API base URL
|
|
1046
1189
|
*/
|
|
1047
|
-
function getBaseUrl() {
|
|
1190
|
+
function getBaseUrl(resourceUrl) {
|
|
1191
|
+
if (typeof resourceUrl === "string" && resourceUrl.length > 0) {
|
|
1192
|
+
return getApiBaseUrl(resourceUrl);
|
|
1193
|
+
}
|
|
1048
1194
|
try {
|
|
1049
1195
|
const stored = loadStoredToken();
|
|
1050
1196
|
if (stored?.resource_url) {
|
|
@@ -1087,14 +1233,15 @@ export const QwenAuthPlugin = async (_input) => {
|
|
|
1087
1233
|
if (model) model.cost = { input: 0, output: 0 };
|
|
1088
1234
|
}
|
|
1089
1235
|
}
|
|
1090
|
-
const
|
|
1091
|
-
if (!accessToken) return null;
|
|
1092
|
-
|
|
1236
|
+
const tokenState = await getValidAccessToken(getAuth);
|
|
1237
|
+
if (!tokenState?.accessToken) return null;
|
|
1238
|
+
ACTIVE_OAUTH_ACCOUNT_ID = tokenState.accountId || null;
|
|
1239
|
+
const baseURL = getBaseUrl(tokenState.resourceUrl);
|
|
1093
1240
|
if (LOGGING_ENABLED) {
|
|
1094
1241
|
logInfo("Using Qwen baseURL:", baseURL);
|
|
1095
1242
|
}
|
|
1096
1243
|
return {
|
|
1097
|
-
apiKey: accessToken,
|
|
1244
|
+
apiKey: tokenState.accessToken,
|
|
1098
1245
|
baseURL,
|
|
1099
1246
|
timeout: CHAT_REQUEST_TIMEOUT_MS,
|
|
1100
1247
|
maxRetries: CHAT_MAX_RETRIES,
|
|
@@ -1138,6 +1285,8 @@ export const QwenAuthPlugin = async (_input) => {
|
|
|
1138
1285
|
const result = await pollForToken(deviceAuth.device_code, pkce.verifier);
|
|
1139
1286
|
if (result.type === "success") {
|
|
1140
1287
|
saveToken(result);
|
|
1288
|
+
const savedAccount = await upsertOAuthAccount(result, { setActive: true });
|
|
1289
|
+
ACTIVE_OAUTH_ACCOUNT_ID = savedAccount?.accountId || ACTIVE_OAUTH_ACCOUNT_ID;
|
|
1141
1290
|
// Return to SDK to save auth state
|
|
1142
1291
|
return {
|
|
1143
1292
|
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 = 24 * 60 * 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