opencode-qwen-cli-auth 2.2.0 → 2.2.2
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/dist/index.js +235 -142
- package/dist/lib/auth/auth.js +327 -167
- package/dist/lib/auth/browser.js +2 -2
- package/dist/lib/config.d.ts +13 -1
- package/dist/lib/config.js +22 -4
- package/dist/lib/constants.d.ts +3 -3
- package/dist/lib/constants.js +4 -4
- package/dist/lib/types.d.ts +8 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,68 +1,90 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Alibaba Qwen OAuth Authentication Plugin for opencode
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* SDK
|
|
6
|
-
*
|
|
7
|
-
* @license MIT with Usage Disclaimer (see LICENSE file)
|
|
8
|
-
* @repository https://github.com/TVD-00/opencode-qwen-cli-auth
|
|
9
|
-
*/
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (accessToken
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Alibaba Qwen OAuth Authentication Plugin for opencode
|
|
3
|
+
*
|
|
4
|
+
* Simple plugin: handles OAuth login + provides apiKey/baseURL to SDK.
|
|
5
|
+
* SDK handles streaming, headers, and request format.
|
|
6
|
+
*
|
|
7
|
+
* @license MIT with Usage Disclaimer (see LICENSE file)
|
|
8
|
+
* @repository https://github.com/TVD-00/opencode-qwen-cli-auth
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { createPKCE, requestDeviceCode, pollForToken, getApiBaseUrl, saveToken, refreshAccessToken, loadStoredToken, getValidToken } from "./lib/auth/auth.js";
|
|
12
|
+
import { PROVIDER_ID, AUTH_LABELS, DEVICE_FLOW, PORTAL_HEADERS } from "./lib/constants.js";
|
|
13
|
+
import { logError, logInfo, logWarn, LOGGING_ENABLED } from "./lib/logger.js";
|
|
14
|
+
const CHAT_REQUEST_TIMEOUT_MS = 30000;
|
|
15
|
+
const CHAT_MAX_RETRIES = 0;
|
|
16
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
|
|
17
|
+
const PLUGIN_USER_AGENT = "opencode-qwen-cli-auth/2.2.1";
|
|
18
|
+
/**
|
|
19
|
+
* Get valid access token from SDK auth state, refresh if expired.
|
|
20
|
+
* Uses getAuth() from SDK instead of reading file directly.
|
|
21
|
+
*
|
|
22
|
+
* @param getAuth - Function to get auth state from SDK
|
|
23
|
+
* @returns Access token or null
|
|
24
|
+
*/
|
|
25
|
+
async function getValidAccessToken(getAuth) {
|
|
26
|
+
const diskToken = await getValidToken();
|
|
27
|
+
if (diskToken?.accessToken) {
|
|
28
|
+
return diskToken.accessToken;
|
|
29
|
+
}
|
|
30
|
+
const auth = await getAuth();
|
|
31
|
+
if (!auth || auth.type !== "oauth") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
let accessToken = auth.access;
|
|
35
|
+
// Refresh if expired (60 second buffer)
|
|
36
|
+
if (accessToken && auth.expires && Date.now() > auth.expires - 60000 && auth.refresh) {
|
|
37
|
+
try {
|
|
38
|
+
const refreshResult = await refreshAccessToken(auth.refresh);
|
|
39
|
+
if (refreshResult.type === "success") {
|
|
40
|
+
accessToken = refreshResult.access;
|
|
41
|
+
saveToken(refreshResult);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
if (LOGGING_ENABLED) {
|
|
45
|
+
logError("Token refresh failed");
|
|
46
|
+
}
|
|
47
|
+
accessToken = undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (LOGGING_ENABLED) {
|
|
52
|
+
logError("Token refresh error:", e);
|
|
53
|
+
}
|
|
54
|
+
accessToken = undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (auth.access && auth.refresh) {
|
|
58
|
+
try {
|
|
59
|
+
saveToken({
|
|
60
|
+
type: "success",
|
|
61
|
+
access: accessToken || auth.access,
|
|
62
|
+
refresh: auth.refresh,
|
|
63
|
+
expires: typeof auth.expires === "number" ? auth.expires : Date.now() + 3600 * 1000,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
logWarn("Failed to bootstrap .qwen token from SDK auth state:", e);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return accessToken ?? null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get base URL from token stored on disk (resource_url).
|
|
74
|
+
* Falls back to portal.qwen.ai/v1 if not available.
|
|
75
|
+
*/
|
|
76
|
+
function getBaseUrl() {
|
|
77
|
+
try {
|
|
78
|
+
const stored = loadStoredToken();
|
|
79
|
+
if (stored?.resource_url) {
|
|
80
|
+
return getApiBaseUrl(stored.resource_url);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
logWarn("Failed to load stored token for baseURL, using default:", e);
|
|
85
|
+
}
|
|
86
|
+
return getApiBaseUrl();
|
|
87
|
+
}
|
|
66
88
|
/**
|
|
67
89
|
* Alibaba Qwen OAuth authentication plugin for opencode
|
|
68
90
|
*
|
|
@@ -78,25 +100,31 @@ export const QwenAuthPlugin = async (_input) => {
|
|
|
78
100
|
return {
|
|
79
101
|
auth: {
|
|
80
102
|
provider: PROVIDER_ID,
|
|
81
|
-
/**
|
|
82
|
-
* Loader:
|
|
83
|
-
* Pattern
|
|
84
|
-
*/
|
|
103
|
+
/**
|
|
104
|
+
* Loader: get token + base URL, return to SDK.
|
|
105
|
+
* Pattern similar to opencode-qwencode-auth reference plugin.
|
|
106
|
+
*/
|
|
85
107
|
async loader(getAuth, provider) {
|
|
86
|
-
// Zero cost
|
|
87
|
-
if (provider?.models) {
|
|
108
|
+
// Zero cost for OAuth models (free)
|
|
109
|
+
if (provider?.models) {
|
|
88
110
|
for (const model of Object.values(provider.models)) {
|
|
89
111
|
if (model) model.cost = { input: 0, output: 0 };
|
|
90
112
|
}
|
|
91
|
-
}
|
|
92
|
-
const accessToken = await getValidAccessToken(getAuth);
|
|
93
|
-
if (!accessToken) return null;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
baseURL:
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
}
|
|
114
|
+
const accessToken = await getValidAccessToken(getAuth);
|
|
115
|
+
if (!accessToken) return null;
|
|
116
|
+
const baseURL = getBaseUrl();
|
|
117
|
+
if (LOGGING_ENABLED) {
|
|
118
|
+
logInfo("Using Qwen baseURL:", baseURL);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
apiKey: accessToken,
|
|
122
|
+
baseURL,
|
|
123
|
+
timeout: CHAT_REQUEST_TIMEOUT_MS,
|
|
124
|
+
maxRetries: CHAT_MAX_RETRIES,
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
methods: [
|
|
100
128
|
{
|
|
101
129
|
label: AUTH_LABELS.OAUTH,
|
|
102
130
|
type: "oauth",
|
|
@@ -111,10 +139,10 @@ export const QwenAuthPlugin = async (_input) => {
|
|
|
111
139
|
if (!deviceAuth) {
|
|
112
140
|
throw new Error("Failed to request device code");
|
|
113
141
|
}
|
|
114
|
-
//
|
|
115
|
-
console.log(`\nPlease visit: ${deviceAuth.verification_uri}`);
|
|
116
|
-
console.log(`And enter code: ${deviceAuth.user_code}\n`);
|
|
117
|
-
// URL
|
|
142
|
+
// Display user code
|
|
143
|
+
console.log(`\nPlease visit: ${deviceAuth.verification_uri}`);
|
|
144
|
+
console.log(`And enter code: ${deviceAuth.user_code}\n`);
|
|
145
|
+
// Verification URL - SDK will open browser automatically when method=auto
|
|
118
146
|
const verificationUrl = deviceAuth.verification_uri_complete || deviceAuth.verification_uri;
|
|
119
147
|
return {
|
|
120
148
|
url: verificationUrl,
|
|
@@ -123,61 +151,93 @@ export const QwenAuthPlugin = async (_input) => {
|
|
|
123
151
|
callback: async () => {
|
|
124
152
|
// Poll for token
|
|
125
153
|
let pollInterval = (deviceAuth.interval || 5) * 1000;
|
|
126
|
-
const POLLING_MARGIN_MS = 3000;
|
|
127
|
-
const maxInterval = DEVICE_FLOW.MAX_POLL_INTERVAL;
|
|
128
|
-
const startTime = Date.now();
|
|
129
|
-
const expiresIn = deviceAuth.expires_in * 1000;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
const POLLING_MARGIN_MS = 3000;
|
|
155
|
+
const maxInterval = DEVICE_FLOW.MAX_POLL_INTERVAL;
|
|
156
|
+
const startTime = Date.now();
|
|
157
|
+
const expiresIn = deviceAuth.expires_in * 1000;
|
|
158
|
+
let consecutivePollFailures = 0;
|
|
159
|
+
while (Date.now() - startTime < expiresIn) {
|
|
160
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval + POLLING_MARGIN_MS));
|
|
161
|
+
const result = await pollForToken(deviceAuth.device_code, pkce.verifier);
|
|
162
|
+
if (result.type === "success") {
|
|
163
|
+
saveToken(result);
|
|
164
|
+
// Return to SDK to save auth state
|
|
165
|
+
return {
|
|
137
166
|
type: "success",
|
|
138
167
|
access: result.access,
|
|
139
168
|
refresh: result.refresh,
|
|
140
|
-
expires: result.expires,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
if (result.type === "slow_down") {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
expires: result.expires,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (result.type === "slow_down") {
|
|
173
|
+
consecutivePollFailures = 0;
|
|
174
|
+
pollInterval = Math.min(pollInterval + 5000, maxInterval);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (result.type === "pending") {
|
|
178
|
+
consecutivePollFailures = 0;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (result.type === "failed") {
|
|
182
|
+
if (result.fatal) {
|
|
183
|
+
logError("OAuth token polling failed with fatal error", {
|
|
184
|
+
status: result.status,
|
|
185
|
+
error: result.error,
|
|
186
|
+
description: result.description,
|
|
187
|
+
});
|
|
188
|
+
return { type: "failed" };
|
|
189
|
+
}
|
|
190
|
+
consecutivePollFailures += 1;
|
|
191
|
+
logWarn(`OAuth token polling failed (${consecutivePollFailures}/${MAX_CONSECUTIVE_POLL_FAILURES})`);
|
|
192
|
+
if (consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
|
|
193
|
+
console.error("[qwen-oauth-plugin] OAuth token polling failed repeatedly");
|
|
194
|
+
return { type: "failed" };
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (result.type === "denied") {
|
|
199
|
+
console.error("[qwen-oauth-plugin] Device authorization was denied");
|
|
200
|
+
return { type: "failed" };
|
|
201
|
+
}
|
|
202
|
+
if (result.type === "expired") {
|
|
203
|
+
console.error("[qwen-oauth-plugin] Device authorization code expired");
|
|
204
|
+
return { type: "failed" };
|
|
205
|
+
}
|
|
206
|
+
return { type: "failed" };
|
|
207
|
+
}
|
|
208
|
+
console.error("[qwen-oauth-plugin] Device authorization timed out");
|
|
209
|
+
return { type: "failed" };
|
|
210
|
+
},
|
|
156
211
|
};
|
|
157
212
|
},
|
|
158
213
|
},
|
|
159
214
|
],
|
|
160
215
|
},
|
|
161
|
-
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* coder-model
|
|
165
|
-
*/
|
|
216
|
+
/**
|
|
217
|
+
* Register qwen-code provider with model list.
|
|
218
|
+
* Only register models that Portal API (OAuth) accepts:
|
|
219
|
+
* coder-model and vision-model (according to QWEN_OAUTH_ALLOWED_MODELS from original CLI)
|
|
220
|
+
*/
|
|
166
221
|
config: async (config) => {
|
|
167
222
|
const providers = config.provider || {};
|
|
168
223
|
providers[PROVIDER_ID] = {
|
|
169
|
-
npm: "@ai-sdk/openai-compatible",
|
|
170
|
-
name: "Qwen Code",
|
|
171
|
-
options: {
|
|
172
|
-
|
|
224
|
+
npm: "@ai-sdk/openai-compatible",
|
|
225
|
+
name: "Qwen Code",
|
|
226
|
+
options: {
|
|
227
|
+
baseURL: getBaseUrl(),
|
|
228
|
+
timeout: CHAT_REQUEST_TIMEOUT_MS,
|
|
229
|
+
maxRetries: CHAT_MAX_RETRIES,
|
|
230
|
+
},
|
|
231
|
+
models: {
|
|
173
232
|
"coder-model": {
|
|
174
233
|
id: "coder-model",
|
|
175
234
|
name: "Qwen Coder (Qwen 3.5 Plus)",
|
|
176
|
-
|
|
235
|
+
// Qwen does not support reasoning_effort from OpenCode UI
|
|
236
|
+
// Thinking is always enabled by default on server side (qwen3.5-plus)
|
|
237
|
+
reasoning: false,
|
|
177
238
|
limit: { context: 1048576, output: 65536 },
|
|
178
239
|
cost: { input: 0, output: 0 },
|
|
179
240
|
modalities: { input: ["text"], output: ["text"] },
|
|
180
|
-
options: { extraBody: { enable_thinking: true } },
|
|
181
241
|
},
|
|
182
242
|
"vision-model": {
|
|
183
243
|
id: "vision-model",
|
|
@@ -188,24 +248,57 @@ export const QwenAuthPlugin = async (_input) => {
|
|
|
188
248
|
modalities: { input: ["text"], output: ["text"] },
|
|
189
249
|
},
|
|
190
250
|
},
|
|
191
|
-
};
|
|
192
|
-
config.provider = providers;
|
|
193
|
-
},
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
251
|
+
};
|
|
252
|
+
config.provider = providers;
|
|
253
|
+
},
|
|
254
|
+
"chat.params": async (input, output) => {
|
|
255
|
+
try {
|
|
256
|
+
output.options = output.options || {};
|
|
257
|
+
output.options.maxRetries = CHAT_MAX_RETRIES;
|
|
258
|
+
if (typeof output.options.timeout !== "number" || output.options.timeout > CHAT_REQUEST_TIMEOUT_MS) {
|
|
259
|
+
output.options.timeout = CHAT_REQUEST_TIMEOUT_MS;
|
|
260
|
+
}
|
|
261
|
+
if (LOGGING_ENABLED) {
|
|
262
|
+
logInfo("Applied chat.params hotfix", {
|
|
263
|
+
sessionID: input?.sessionID,
|
|
264
|
+
modelID: input?.model?.id,
|
|
265
|
+
timeout: output.options.timeout,
|
|
266
|
+
maxRetries: output.options.maxRetries,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
logWarn("Failed to apply chat params hotfix:", e);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
/**
|
|
275
|
+
* Send DashScope headers like original CLI.
|
|
276
|
+
* X-DashScope-CacheControl: enable prompt caching, reduce token consumption.
|
|
277
|
+
* X-DashScope-AuthType: specify auth method for server.
|
|
278
|
+
*/
|
|
279
|
+
"chat.headers": async (input, output) => {
|
|
280
|
+
try {
|
|
281
|
+
output.headers = output.headers || {};
|
|
282
|
+
const requestId = randomUUID();
|
|
283
|
+
output.headers["X-DashScope-CacheControl"] = "enable";
|
|
284
|
+
output.headers[PORTAL_HEADERS.AUTH_TYPE] = PORTAL_HEADERS.AUTH_TYPE_VALUE;
|
|
285
|
+
output.headers["User-Agent"] = PLUGIN_USER_AGENT;
|
|
286
|
+
output.headers["X-DashScope-UserAgent"] = PLUGIN_USER_AGENT;
|
|
287
|
+
output.headers["x-request-id"] = requestId;
|
|
288
|
+
if (LOGGING_ENABLED) {
|
|
289
|
+
logInfo("Applied chat.headers", {
|
|
290
|
+
request_id: requestId,
|
|
291
|
+
sessionID: input?.sessionID,
|
|
292
|
+
modelID: input?.model?.id,
|
|
293
|
+
providerID: input?.provider?.info?.id,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
logWarn("Failed to set chat headers:", e);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
};
|
|
210
303
|
export default QwenAuthPlugin;
|
|
211
|
-
//# sourceMappingURL=index.js.map
|
|
304
|
+
//# sourceMappingURL=index.js.map
|
package/dist/lib/auth/auth.js
CHANGED
|
@@ -1,27 +1,52 @@
|
|
|
1
1
|
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, statSync } from "fs";
|
|
3
3
|
import { QWEN_OAUTH, DEFAULT_QWEN_BASE_URL, TOKEN_REFRESH_BUFFER_MS, VERIFICATION_URI } from "../constants.js";
|
|
4
|
-
import { getTokenPath,
|
|
4
|
+
import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
|
|
5
5
|
import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
|
|
6
|
-
|
|
7
|
-
const MAX_REFRESH_RETRIES = 2;
|
|
6
|
+
const MAX_REFRESH_RETRIES = 1;
|
|
8
7
|
const REFRESH_RETRY_DELAY_MS = 1000;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
const OAUTH_REQUEST_TIMEOUT_MS = 15000;
|
|
9
|
+
const LOCK_TIMEOUT_MS = 10000;
|
|
10
|
+
const LOCK_ATTEMPT_INTERVAL_MS = 100;
|
|
11
|
+
const LOCK_BACKOFF_MULTIPLIER = 1.5;
|
|
12
|
+
const LOCK_MAX_INTERVAL_MS = 2000;
|
|
13
|
+
const LOCK_MAX_ATTEMPTS = 20;
|
|
14
|
+
function isAbortError(error) {
|
|
15
|
+
return typeof error === "object" && error !== null && ("name" in error) && error.name === "AbortError";
|
|
16
|
+
}
|
|
17
|
+
function hasErrorCode(error, code) {
|
|
18
|
+
return typeof error === "object" && error !== null && ("code" in error) && error.code === code;
|
|
19
|
+
}
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
26
|
+
try {
|
|
27
|
+
return await fetch(url, {
|
|
28
|
+
...init,
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (isAbortError(error)) {
|
|
34
|
+
throw new Error(`OAuth request timed out after ${timeoutMs}ms`);
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
14
42
|
function normalizeResourceUrl(resourceUrl) {
|
|
15
43
|
if (!resourceUrl)
|
|
16
44
|
return undefined;
|
|
17
45
|
try {
|
|
18
|
-
// Qwen returns resource_url without protocol (e.g., "portal.qwen.ai")
|
|
19
|
-
// Normalize it by adding https:// if missing
|
|
20
46
|
let normalizedUrl = resourceUrl;
|
|
21
47
|
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
|
22
48
|
normalizedUrl = `https://${normalizedUrl}`;
|
|
23
49
|
}
|
|
24
|
-
// Validate the normalized URL
|
|
25
50
|
new URL(normalizedUrl);
|
|
26
51
|
if (LOGGING_ENABLED) {
|
|
27
52
|
logInfo("Valid resource_url found and normalized:", normalizedUrl);
|
|
@@ -33,35 +58,168 @@ function normalizeResourceUrl(resourceUrl) {
|
|
|
33
58
|
return undefined;
|
|
34
59
|
}
|
|
35
60
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Validate token response fields
|
|
38
|
-
* @param json - Token response JSON
|
|
39
|
-
* @param context - Context for logging (e.g., "token response" or "refresh response")
|
|
40
|
-
* @returns True if valid, false otherwise
|
|
41
|
-
*/
|
|
42
61
|
function validateTokenResponse(json, context) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
logError(`${context} missing fields:`, json);
|
|
62
|
+
if (!json.access_token || typeof json.access_token !== "string") {
|
|
63
|
+
logError(`${context} missing access_token`);
|
|
46
64
|
return false;
|
|
47
65
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
if (!json.refresh_token || typeof json.refresh_token !== "string") {
|
|
67
|
+
logError(`${context} missing refresh_token`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (typeof json.expires_in !== "number" || json.expires_in <= 0) {
|
|
71
|
+
logError(`${context} invalid expires_in:`, json.expires_in);
|
|
51
72
|
return false;
|
|
52
73
|
}
|
|
53
74
|
return true;
|
|
54
75
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
function toStoredTokenData(data) {
|
|
77
|
+
if (!data || typeof data !== "object") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const raw = data;
|
|
81
|
+
const accessToken = typeof raw.access_token === "string" ? raw.access_token : undefined;
|
|
82
|
+
const refreshToken = typeof raw.refresh_token === "string" ? raw.refresh_token : undefined;
|
|
83
|
+
const tokenType = typeof raw.token_type === "string" && raw.token_type.length > 0 ? raw.token_type : "Bearer";
|
|
84
|
+
const expiryDate = typeof raw.expiry_date === "number"
|
|
85
|
+
? raw.expiry_date
|
|
86
|
+
: typeof raw.expires === "number"
|
|
87
|
+
? raw.expires
|
|
88
|
+
: typeof raw.expiry_date === "string"
|
|
89
|
+
? Number(raw.expiry_date)
|
|
90
|
+
: undefined;
|
|
91
|
+
const resourceUrl = typeof raw.resource_url === "string" ? raw.resource_url : undefined;
|
|
92
|
+
if (!accessToken || !refreshToken || typeof expiryDate !== "number" || !Number.isFinite(expiryDate) || expiryDate <= 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
access_token: accessToken,
|
|
97
|
+
refresh_token: refreshToken,
|
|
98
|
+
token_type: tokenType,
|
|
99
|
+
expiry_date: expiryDate,
|
|
100
|
+
resource_url: resourceUrl,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function buildTokenSuccessFromStored(stored) {
|
|
104
|
+
return {
|
|
105
|
+
type: "success",
|
|
106
|
+
access: stored.access_token,
|
|
107
|
+
refresh: stored.refresh_token,
|
|
108
|
+
expires: stored.expiry_date,
|
|
109
|
+
resourceUrl: stored.resource_url,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function writeStoredTokenData(tokenData) {
|
|
113
|
+
const qwenDir = getQwenDir();
|
|
114
|
+
if (!existsSync(qwenDir)) {
|
|
115
|
+
mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
|
|
116
|
+
}
|
|
117
|
+
const tokenPath = getTokenPath();
|
|
118
|
+
const tempPath = `${tokenPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
|
119
|
+
try {
|
|
120
|
+
writeFileSync(tempPath, JSON.stringify(tokenData, null, 2), {
|
|
121
|
+
encoding: "utf-8",
|
|
122
|
+
mode: 0o600,
|
|
123
|
+
});
|
|
124
|
+
renameSync(tempPath, tokenPath);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
try {
|
|
128
|
+
if (existsSync(tempPath)) {
|
|
129
|
+
unlinkSync(tempPath);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (_cleanupError) {
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function migrateLegacyTokenIfNeeded() {
|
|
138
|
+
const tokenPath = getTokenPath();
|
|
139
|
+
if (existsSync(tokenPath)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const legacyPath = getLegacyTokenPath();
|
|
143
|
+
if (!existsSync(legacyPath)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const legacyRaw = readFileSync(legacyPath, "utf-8");
|
|
148
|
+
const legacyData = JSON.parse(legacyRaw);
|
|
149
|
+
const converted = toStoredTokenData(legacyData);
|
|
150
|
+
if (!converted) {
|
|
151
|
+
logWarn("Legacy token found but invalid, skipping migration");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
writeStoredTokenData(converted);
|
|
155
|
+
logInfo("Migrated token from legacy path to ~/.qwen/oauth_creds.json");
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
logWarn("Failed to migrate legacy token:", error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function acquireTokenLock() {
|
|
162
|
+
const lockPath = getTokenLockPath();
|
|
163
|
+
const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
164
|
+
let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
|
|
165
|
+
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
|
|
166
|
+
try {
|
|
167
|
+
writeFileSync(lockPath, lockValue, {
|
|
168
|
+
encoding: "utf-8",
|
|
169
|
+
flag: "wx",
|
|
170
|
+
mode: 0o600,
|
|
171
|
+
});
|
|
172
|
+
return lockPath;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (!hasErrorCode(error, "EEXIST")) {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const stats = statSync(lockPath);
|
|
180
|
+
const ageMs = Date.now() - stats.mtimeMs;
|
|
181
|
+
if (ageMs > LOCK_TIMEOUT_MS) {
|
|
182
|
+
try {
|
|
183
|
+
unlinkSync(lockPath);
|
|
184
|
+
logWarn("Removed stale token lock file", { lockPath, ageMs });
|
|
185
|
+
}
|
|
186
|
+
catch (staleError) {
|
|
187
|
+
if (!hasErrorCode(staleError, "ENOENT")) {
|
|
188
|
+
logWarn("Failed to remove stale token lock", staleError);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (statError) {
|
|
195
|
+
if (!hasErrorCode(statError, "ENOENT")) {
|
|
196
|
+
logWarn("Failed to inspect token lock file", statError);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
await sleep(waitMs);
|
|
200
|
+
waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
throw new Error("Token refresh lock timeout");
|
|
204
|
+
}
|
|
205
|
+
function releaseTokenLock(lockPath) {
|
|
206
|
+
try {
|
|
207
|
+
unlinkSync(lockPath);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (!hasErrorCode(error, "ENOENT")) {
|
|
211
|
+
logWarn("Failed to release token lock file", error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
60
215
|
export async function requestDeviceCode(pkce) {
|
|
61
216
|
try {
|
|
62
|
-
const res = await
|
|
217
|
+
const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
|
|
63
218
|
method: "POST",
|
|
64
|
-
headers: {
|
|
219
|
+
headers: {
|
|
220
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
221
|
+
Accept: "application/json",
|
|
222
|
+
},
|
|
65
223
|
body: new URLSearchParams({
|
|
66
224
|
client_id: QWEN_OAUTH.CLIENT_ID,
|
|
67
225
|
scope: QWEN_OAUTH.SCOPE,
|
|
@@ -82,11 +240,9 @@ export async function requestDeviceCode(pkce) {
|
|
|
82
240
|
logError("device code response missing fields:", json);
|
|
83
241
|
return null;
|
|
84
242
|
}
|
|
85
|
-
// Ensure verification_uri_complete includes the client parameter
|
|
86
|
-
// Qwen's OAuth server requires client=qwen-code for proper authentication
|
|
87
243
|
if (!json.verification_uri_complete || !json.verification_uri_complete.includes(VERIFICATION_URI.CLIENT_PARAM_KEY)) {
|
|
88
244
|
const baseUrl = json.verification_uri_complete || json.verification_uri;
|
|
89
|
-
const separator = baseUrl.includes(
|
|
245
|
+
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
90
246
|
json.verification_uri_complete = `${baseUrl}${separator}${VERIFICATION_URI.CLIENT_PARAM_VALUE}`;
|
|
91
247
|
if (LOGGING_ENABLED) {
|
|
92
248
|
logInfo("Fixed verification_uri_complete:", json.verification_uri_complete);
|
|
@@ -99,18 +255,14 @@ export async function requestDeviceCode(pkce) {
|
|
|
99
255
|
return null;
|
|
100
256
|
}
|
|
101
257
|
}
|
|
102
|
-
/**
|
|
103
|
-
* Poll for token using device code
|
|
104
|
-
* @param deviceCode - Device code from authorization response
|
|
105
|
-
* @param verifier - PKCE verifier
|
|
106
|
-
* @param interval - Polling interval in seconds (from device response)
|
|
107
|
-
* @returns Token result or null if still pending
|
|
108
|
-
*/
|
|
109
258
|
export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
110
259
|
try {
|
|
111
|
-
const res = await
|
|
260
|
+
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
112
261
|
method: "POST",
|
|
113
|
-
headers: {
|
|
262
|
+
headers: {
|
|
263
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
264
|
+
Accept: "application/json",
|
|
265
|
+
},
|
|
114
266
|
body: new URLSearchParams({
|
|
115
267
|
grant_type: QWEN_OAUTH.GRANT_TYPE_DEVICE,
|
|
116
268
|
client_id: QWEN_OAUTH.CLIENT_ID,
|
|
@@ -120,26 +272,35 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
120
272
|
});
|
|
121
273
|
if (!res.ok) {
|
|
122
274
|
const json = await res.json().catch(() => ({}));
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (
|
|
275
|
+
const errorCode = typeof json.error === "string" ? json.error : undefined;
|
|
276
|
+
const errorDescription = typeof json.error_description === "string" ? json.error_description : "No details provided";
|
|
277
|
+
if (errorCode === "authorization_pending") {
|
|
126
278
|
return { type: "pending" };
|
|
127
279
|
}
|
|
128
|
-
if (
|
|
280
|
+
if (errorCode === "slow_down") {
|
|
129
281
|
return { type: "slow_down" };
|
|
130
282
|
}
|
|
131
|
-
if (
|
|
283
|
+
if (errorCode === "expired_token") {
|
|
132
284
|
return { type: "expired" };
|
|
133
285
|
}
|
|
134
|
-
if (
|
|
286
|
+
if (errorCode === "access_denied") {
|
|
135
287
|
return { type: "denied" };
|
|
136
288
|
}
|
|
137
|
-
logError("token poll failed:", {
|
|
138
|
-
|
|
289
|
+
logError("token poll failed:", {
|
|
290
|
+
status: res.status,
|
|
291
|
+
error: errorCode,
|
|
292
|
+
description: errorDescription,
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
type: "failed",
|
|
296
|
+
status: res.status,
|
|
297
|
+
error: errorCode || "unknown_error",
|
|
298
|
+
description: errorDescription,
|
|
299
|
+
fatal: true,
|
|
300
|
+
};
|
|
139
301
|
}
|
|
140
302
|
const json = await res.json();
|
|
141
303
|
if (LOGGING_ENABLED) {
|
|
142
|
-
// Log the full token response for debugging
|
|
143
304
|
logInfo("Token response received:", {
|
|
144
305
|
has_access_token: !!json.access_token,
|
|
145
306
|
has_refresh_token: !!json.refresh_token,
|
|
@@ -148,39 +309,46 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
148
309
|
all_fields: Object.keys(json),
|
|
149
310
|
});
|
|
150
311
|
}
|
|
151
|
-
// Validate token response fields
|
|
152
312
|
if (!validateTokenResponse(json, "token response")) {
|
|
153
|
-
return {
|
|
313
|
+
return {
|
|
314
|
+
type: "failed",
|
|
315
|
+
error: "invalid_token_response",
|
|
316
|
+
description: "Token response missing required fields",
|
|
317
|
+
fatal: true,
|
|
318
|
+
};
|
|
154
319
|
}
|
|
155
|
-
// Validate and normalize resource_url if present
|
|
156
320
|
json.resource_url = normalizeResourceUrl(json.resource_url);
|
|
157
321
|
if (!json.resource_url) {
|
|
158
322
|
logWarn("No valid resource_url in token response, will use default DashScope endpoint");
|
|
159
323
|
}
|
|
160
|
-
// At this point, validation ensures these fields exist
|
|
161
324
|
return {
|
|
162
325
|
type: "success",
|
|
163
326
|
access: json.access_token,
|
|
164
327
|
refresh: json.refresh_token,
|
|
165
328
|
expires: Date.now() + json.expires_in * 1000,
|
|
166
|
-
resourceUrl: json.resource_url,
|
|
329
|
+
resourceUrl: json.resource_url,
|
|
167
330
|
};
|
|
168
331
|
}
|
|
169
332
|
catch (error) {
|
|
170
|
-
|
|
171
|
-
|
|
333
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
334
|
+
const lowered = message.toLowerCase();
|
|
335
|
+
const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
|
|
336
|
+
logWarn("token poll failed:", { message, transient });
|
|
337
|
+
return {
|
|
338
|
+
type: "failed",
|
|
339
|
+
error: message,
|
|
340
|
+
fatal: !transient,
|
|
341
|
+
};
|
|
172
342
|
}
|
|
173
343
|
}
|
|
174
|
-
/**
|
|
175
|
-
* Refresh access token using refresh token (1 lan duy nhat, khong retry)
|
|
176
|
-
* @param refreshToken - Refresh token
|
|
177
|
-
* @returns Token result
|
|
178
|
-
*/
|
|
179
344
|
async function refreshAccessTokenOnce(refreshToken) {
|
|
180
345
|
try {
|
|
181
|
-
const res = await
|
|
346
|
+
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
182
347
|
method: "POST",
|
|
183
|
-
headers: {
|
|
348
|
+
headers: {
|
|
349
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
350
|
+
Accept: "application/json",
|
|
351
|
+
},
|
|
184
352
|
body: new URLSearchParams({
|
|
185
353
|
grant_type: QWEN_OAUTH.GRANT_TYPE_REFRESH,
|
|
186
354
|
client_id: QWEN_OAUTH.CLIENT_ID,
|
|
@@ -189,8 +357,17 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
189
357
|
});
|
|
190
358
|
if (!res.ok) {
|
|
191
359
|
const text = await res.text().catch(() => "");
|
|
360
|
+
const lowered = text.toLowerCase();
|
|
361
|
+
const isUnauthorized = res.status === 401 || res.status === 403;
|
|
362
|
+
const isRateLimited = res.status === 429;
|
|
363
|
+
const transient = res.status >= 500 || lowered.includes("timed out") || lowered.includes("network");
|
|
192
364
|
logError("token refresh failed:", { status: res.status, text });
|
|
193
|
-
return {
|
|
365
|
+
return {
|
|
366
|
+
type: "failed",
|
|
367
|
+
status: res.status,
|
|
368
|
+
error: text || `HTTP ${res.status}`,
|
|
369
|
+
fatal: isUnauthorized || isRateLimited || !transient,
|
|
370
|
+
};
|
|
194
371
|
}
|
|
195
372
|
const json = await res.json();
|
|
196
373
|
if (LOGGING_ENABLED) {
|
|
@@ -202,11 +379,14 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
202
379
|
all_fields: Object.keys(json),
|
|
203
380
|
});
|
|
204
381
|
}
|
|
205
|
-
// Validate token response fields
|
|
206
382
|
if (!validateTokenResponse(json, "refresh response")) {
|
|
207
|
-
return {
|
|
383
|
+
return {
|
|
384
|
+
type: "failed",
|
|
385
|
+
error: "invalid_refresh_response",
|
|
386
|
+
description: "Refresh response missing required fields",
|
|
387
|
+
fatal: true,
|
|
388
|
+
};
|
|
208
389
|
}
|
|
209
|
-
// Validate and normalize resource_url if present
|
|
210
390
|
json.resource_url = normalizeResourceUrl(json.resource_url);
|
|
211
391
|
if (!json.resource_url) {
|
|
212
392
|
logWarn("No valid resource_url in refresh response, will use default DashScope endpoint");
|
|
@@ -220,200 +400,180 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
220
400
|
};
|
|
221
401
|
}
|
|
222
402
|
catch (error) {
|
|
223
|
-
|
|
224
|
-
|
|
403
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
404
|
+
const lowered = message.toLowerCase();
|
|
405
|
+
const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
|
|
406
|
+
logError("token refresh error:", { message, transient });
|
|
407
|
+
return {
|
|
408
|
+
type: "failed",
|
|
409
|
+
error: message,
|
|
410
|
+
fatal: !transient,
|
|
411
|
+
};
|
|
225
412
|
}
|
|
226
413
|
}
|
|
227
|
-
/**
|
|
228
|
-
* Refresh access token voi retry logic
|
|
229
|
-
* Retry toi da MAX_REFRESH_RETRIES lan voi delay giua cac lan
|
|
230
|
-
* @param refreshToken - Refresh token
|
|
231
|
-
* @returns Token result
|
|
232
|
-
*/
|
|
233
414
|
export async function refreshAccessToken(refreshToken) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
// Neu loi 401/403 thi refresh token da bi revoke, khong can retry
|
|
240
|
-
if (result.status === 401 || result.status === 403) {
|
|
241
|
-
logError("Refresh token bi reject (" + result.status + "), can dang nhap lai");
|
|
242
|
-
return { type: "failed" };
|
|
415
|
+
const lockPath = await acquireTokenLock();
|
|
416
|
+
try {
|
|
417
|
+
const latest = loadStoredToken();
|
|
418
|
+
if (latest && !isTokenExpired(latest.expiry_date)) {
|
|
419
|
+
return buildTokenSuccessFromStored(latest);
|
|
243
420
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
421
|
+
const effectiveRefreshToken = latest?.refresh_token || refreshToken;
|
|
422
|
+
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
|
|
423
|
+
const result = await refreshAccessTokenOnce(effectiveRefreshToken);
|
|
424
|
+
if (result.type === "success") {
|
|
425
|
+
saveToken(result);
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
if (result.status === 401 || result.status === 403) {
|
|
429
|
+
logError(`Refresh token rejected (${result.status}), re-authentication required`);
|
|
430
|
+
clearStoredToken();
|
|
431
|
+
return { type: "failed", status: result.status, error: "refresh_token_rejected", fatal: true };
|
|
432
|
+
}
|
|
433
|
+
if (result.status === 429) {
|
|
434
|
+
logError("Token refresh rate-limited (429), aborting retries");
|
|
435
|
+
return { type: "failed", status: 429, error: "rate_limited", fatal: true };
|
|
436
|
+
}
|
|
437
|
+
if (result.fatal) {
|
|
438
|
+
logError("Token refresh failed with fatal error", result);
|
|
439
|
+
return result;
|
|
440
|
+
}
|
|
441
|
+
if (attempt < MAX_REFRESH_RETRIES) {
|
|
442
|
+
if (LOGGING_ENABLED) {
|
|
443
|
+
logInfo(`Token refresh transient failure, retrying attempt ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
|
|
444
|
+
}
|
|
445
|
+
await sleep(REFRESH_RETRY_DELAY_MS);
|
|
248
446
|
}
|
|
249
|
-
await new Promise(resolve => setTimeout(resolve, REFRESH_RETRY_DELAY_MS));
|
|
250
447
|
}
|
|
448
|
+
logError("Token refresh failed after retry limit");
|
|
449
|
+
return { type: "failed", error: "refresh_failed" };
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
releaseTokenLock(lockPath);
|
|
251
453
|
}
|
|
252
|
-
logError("Token refresh that bai sau " + (MAX_REFRESH_RETRIES + 1) + " lan thu");
|
|
253
|
-
return { type: "failed" };
|
|
254
454
|
}
|
|
255
|
-
/**
|
|
256
|
-
* Generate PKCE challenge and verifier
|
|
257
|
-
* @returns PKCE pair
|
|
258
|
-
*/
|
|
259
455
|
export async function createPKCE() {
|
|
260
456
|
const { challenge, verifier } = await generatePKCE();
|
|
261
457
|
return { challenge, verifier };
|
|
262
458
|
}
|
|
263
|
-
/**
|
|
264
|
-
* Load stored token from disk
|
|
265
|
-
* @returns Stored token data or null if not found
|
|
266
|
-
*/
|
|
267
459
|
export function loadStoredToken() {
|
|
460
|
+
migrateLegacyTokenIfNeeded();
|
|
268
461
|
const tokenPath = getTokenPath();
|
|
269
462
|
if (!existsSync(tokenPath)) {
|
|
270
463
|
return null;
|
|
271
464
|
}
|
|
272
465
|
try {
|
|
273
466
|
const content = readFileSync(tokenPath, "utf-8");
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
if (!
|
|
467
|
+
const parsed = JSON.parse(content);
|
|
468
|
+
const normalized = toStoredTokenData(parsed);
|
|
469
|
+
if (!normalized) {
|
|
277
470
|
logWarn("Invalid token data, re-authentication required");
|
|
278
471
|
return null;
|
|
279
472
|
}
|
|
280
|
-
|
|
473
|
+
const needsRewrite = typeof parsed.expiry_date !== "number" || typeof parsed.token_type !== "string" || typeof parsed.expires === "number";
|
|
474
|
+
if (needsRewrite) {
|
|
475
|
+
try {
|
|
476
|
+
writeStoredTokenData(normalized);
|
|
477
|
+
}
|
|
478
|
+
catch (rewriteError) {
|
|
479
|
+
logWarn("Failed to normalize token file format:", rewriteError);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return normalized;
|
|
281
483
|
}
|
|
282
484
|
catch (error) {
|
|
283
485
|
logError("Failed to load token:", error);
|
|
284
486
|
return null;
|
|
285
487
|
}
|
|
286
488
|
}
|
|
287
|
-
/**
|
|
288
|
-
* Xoa token luu tren disk khi token khong con hop le
|
|
289
|
-
*/
|
|
290
489
|
export function clearStoredToken() {
|
|
291
|
-
const
|
|
292
|
-
|
|
490
|
+
const targets = [getTokenPath(), getLegacyTokenPath()];
|
|
491
|
+
for (const tokenPath of targets) {
|
|
492
|
+
if (!existsSync(tokenPath)) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
293
495
|
try {
|
|
294
496
|
unlinkSync(tokenPath);
|
|
295
|
-
logWarn(
|
|
497
|
+
logWarn(`Deleted token file: ${tokenPath}`);
|
|
296
498
|
}
|
|
297
499
|
catch (error) {
|
|
298
|
-
logError("
|
|
500
|
+
logError("Unable to delete token file:", { tokenPath, error });
|
|
299
501
|
}
|
|
300
502
|
}
|
|
301
503
|
}
|
|
302
|
-
/**
|
|
303
|
-
* Save token to disk
|
|
304
|
-
* @param tokenResult - Token result from OAuth flow
|
|
305
|
-
*/
|
|
306
504
|
export function saveToken(tokenResult) {
|
|
307
505
|
if (tokenResult.type !== "success") {
|
|
308
506
|
throw new Error("Cannot save non-success token result");
|
|
309
507
|
}
|
|
310
|
-
const configDir = getConfigDir();
|
|
311
|
-
// Ensure directory exists
|
|
312
|
-
if (!existsSync(configDir)) {
|
|
313
|
-
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
314
|
-
}
|
|
315
508
|
const tokenData = {
|
|
316
509
|
access_token: tokenResult.access,
|
|
317
510
|
refresh_token: tokenResult.refresh,
|
|
318
|
-
|
|
511
|
+
token_type: "Bearer",
|
|
512
|
+
expiry_date: tokenResult.expires,
|
|
319
513
|
resource_url: tokenResult.resourceUrl,
|
|
320
514
|
};
|
|
321
|
-
const tokenPath = getTokenPath();
|
|
322
515
|
try {
|
|
323
|
-
|
|
324
|
-
encoding: "utf-8",
|
|
325
|
-
mode: 0o600, // Secure permissions
|
|
326
|
-
});
|
|
516
|
+
writeStoredTokenData(tokenData);
|
|
327
517
|
}
|
|
328
518
|
catch (error) {
|
|
329
519
|
logError("Failed to save token:", error);
|
|
330
520
|
throw error;
|
|
331
521
|
}
|
|
332
522
|
}
|
|
333
|
-
/**
|
|
334
|
-
* Check if token is expired (with 5 minute buffer)
|
|
335
|
-
* @param expiresAt - Expiration timestamp in milliseconds
|
|
336
|
-
* @returns True if token is expired or will expire soon
|
|
337
|
-
*/
|
|
338
523
|
export function isTokenExpired(expiresAt) {
|
|
339
524
|
return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
340
525
|
}
|
|
341
|
-
/**
|
|
342
|
-
* Get valid access token, refreshing if necessary
|
|
343
|
-
* Khi refresh that bai, xoa token cu de user biet can dang nhap lai
|
|
344
|
-
* @returns Access token and resource URL, or null if authentication required
|
|
345
|
-
*/
|
|
346
526
|
export async function getValidToken() {
|
|
347
527
|
const stored = loadStoredToken();
|
|
348
528
|
if (!stored) {
|
|
349
|
-
return null;
|
|
529
|
+
return null;
|
|
350
530
|
}
|
|
351
|
-
|
|
352
|
-
if (!isTokenExpired(stored.expires)) {
|
|
531
|
+
if (!isTokenExpired(stored.expiry_date)) {
|
|
353
532
|
return {
|
|
354
533
|
accessToken: stored.access_token,
|
|
355
534
|
resourceUrl: stored.resource_url,
|
|
356
535
|
};
|
|
357
536
|
}
|
|
358
|
-
// Token het han, thu refresh (co retry ben trong)
|
|
359
537
|
if (LOGGING_ENABLED) {
|
|
360
538
|
logInfo("Token expired, refreshing...");
|
|
361
539
|
}
|
|
362
540
|
const refreshResult = await refreshAccessToken(stored.refresh_token);
|
|
363
541
|
if (refreshResult.type !== "success") {
|
|
364
542
|
logError("Token refresh failed, re-authentication required");
|
|
365
|
-
// Xoa token cu de tranh loop loi
|
|
366
543
|
clearStoredToken();
|
|
367
544
|
return null;
|
|
368
545
|
}
|
|
369
|
-
// Luu token moi
|
|
370
|
-
saveToken(refreshResult);
|
|
371
546
|
return {
|
|
372
547
|
accessToken: refreshResult.access,
|
|
373
548
|
resourceUrl: refreshResult.resourceUrl,
|
|
374
549
|
};
|
|
375
550
|
}
|
|
376
|
-
/**
|
|
377
|
-
* Get Portal API base URL from token or use default
|
|
378
|
-
* @param resourceUrl - Resource URL from token (optional)
|
|
379
|
-
* @returns Portal API base URL
|
|
380
|
-
*
|
|
381
|
-
* IMPORTANT: Portal API uses /v1 path (not /api/v1)
|
|
382
|
-
* - OAuth endpoints: /api/v1/oauth2/ (for authentication)
|
|
383
|
-
* - Chat API: /v1/ (for completions)
|
|
384
|
-
*/
|
|
385
551
|
export function getApiBaseUrl(resourceUrl) {
|
|
386
552
|
if (resourceUrl) {
|
|
387
|
-
// Validate URL format
|
|
388
553
|
try {
|
|
389
554
|
const url = new URL(resourceUrl);
|
|
390
|
-
if (!url.protocol.startsWith(
|
|
391
|
-
logWarn(
|
|
555
|
+
if (!url.protocol.startsWith("http")) {
|
|
556
|
+
logWarn("Invalid resource_url protocol, using default Portal API URL");
|
|
392
557
|
return DEFAULT_QWEN_BASE_URL;
|
|
393
558
|
}
|
|
394
|
-
// Construct the Portal API endpoint from resource_url
|
|
395
|
-
// Qwen returns "portal.qwen.ai" which should become "https://portal.qwen.ai/v1"
|
|
396
|
-
// Remove trailing slash if present
|
|
397
559
|
let baseUrl = resourceUrl.replace(/\/$/, "");
|
|
398
|
-
|
|
399
|
-
const suffix = '/v1';
|
|
560
|
+
const suffix = "/v1";
|
|
400
561
|
if (!baseUrl.endsWith(suffix)) {
|
|
401
562
|
baseUrl = `${baseUrl}${suffix}`;
|
|
402
563
|
}
|
|
403
564
|
if (LOGGING_ENABLED) {
|
|
404
|
-
logInfo(
|
|
565
|
+
logInfo("Constructed Portal API base URL from resource_url:", baseUrl);
|
|
405
566
|
}
|
|
406
567
|
return baseUrl;
|
|
407
568
|
}
|
|
408
569
|
catch (error) {
|
|
409
|
-
logWarn(
|
|
570
|
+
logWarn("Invalid resource_url format, using default Portal API URL:", error);
|
|
410
571
|
return DEFAULT_QWEN_BASE_URL;
|
|
411
572
|
}
|
|
412
573
|
}
|
|
413
|
-
// Fall back to default Portal API URL
|
|
414
574
|
if (LOGGING_ENABLED) {
|
|
415
|
-
logInfo(
|
|
575
|
+
logInfo("No resource_url provided, using default Portal API URL");
|
|
416
576
|
}
|
|
417
577
|
return DEFAULT_QWEN_BASE_URL;
|
|
418
578
|
}
|
|
419
|
-
//# sourceMappingURL=auth.js.map
|
|
579
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/lib/auth/browser.js
CHANGED
|
@@ -30,8 +30,8 @@ export function openBrowserUrl(url) {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
catch (error) {
|
|
33
|
-
// Log
|
|
34
|
-
console.warn("[qwen-oauth-plugin]
|
|
33
|
+
// Log warning for debugging, user can still open URL manually
|
|
34
|
+
console.warn("[qwen-oauth-plugin] Unable to open browser:", error?.message || error);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
//# sourceMappingURL=browser.js.map
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ import type { PluginConfig } from "./types.js";
|
|
|
3
3
|
* Get plugin configuration directory
|
|
4
4
|
*/
|
|
5
5
|
export declare function getConfigDir(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Get Qwen CLI credential directory (~/.qwen)
|
|
8
|
+
*/
|
|
9
|
+
export declare function getQwenDir(): string;
|
|
6
10
|
/**
|
|
7
11
|
* Get plugin configuration file path
|
|
8
12
|
*/
|
|
@@ -21,8 +25,16 @@ export declare function getQwenMode(config: PluginConfig): boolean;
|
|
|
21
25
|
* Get token storage path
|
|
22
26
|
*/
|
|
23
27
|
export declare function getTokenPath(): string;
|
|
28
|
+
/**
|
|
29
|
+
* Get token lock path for multi-process refresh coordination
|
|
30
|
+
*/
|
|
31
|
+
export declare function getTokenLockPath(): string;
|
|
32
|
+
/**
|
|
33
|
+
* Get legacy token storage path used by old plugin versions
|
|
34
|
+
*/
|
|
35
|
+
export declare function getLegacyTokenPath(): string;
|
|
24
36
|
/**
|
|
25
37
|
* Get cache directory for prompts
|
|
26
38
|
*/
|
|
27
39
|
export declare function getCacheDir(): string;
|
|
28
|
-
//# sourceMappingURL=config.d.ts.map
|
|
40
|
+
//# sourceMappingURL=config.d.ts.map
|
package/dist/lib/config.js
CHANGED
|
@@ -7,6 +7,12 @@ import { readFileSync, existsSync } from "fs";
|
|
|
7
7
|
export function getConfigDir() {
|
|
8
8
|
return join(homedir(), ".opencode", "qwen");
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Get Qwen CLI credential directory (~/.qwen)
|
|
12
|
+
*/
|
|
13
|
+
export function getQwenDir() {
|
|
14
|
+
return join(homedir(), ".qwen");
|
|
15
|
+
}
|
|
10
16
|
/**
|
|
11
17
|
* Get plugin configuration file path
|
|
12
18
|
*/
|
|
@@ -20,7 +26,7 @@ export function getConfigPath() {
|
|
|
20
26
|
export function loadPluginConfig() {
|
|
21
27
|
const configPath = getConfigPath();
|
|
22
28
|
if (!existsSync(configPath)) {
|
|
23
|
-
return { qwenMode: true }; // Default
|
|
29
|
+
return { qwenMode: true }; // Default: QWEN_MODE enabled
|
|
24
30
|
}
|
|
25
31
|
try {
|
|
26
32
|
const content = readFileSync(configPath, "utf-8");
|
|
@@ -40,9 +46,9 @@ export function getQwenMode(config) {
|
|
|
40
46
|
if (envValue !== undefined) {
|
|
41
47
|
return envValue === "1" || envValue.toLowerCase() === "true";
|
|
42
48
|
}
|
|
43
|
-
//
|
|
49
|
+
// Ensure boolean type, avoid string "false" being truthy
|
|
44
50
|
const val = config.qwenMode;
|
|
45
|
-
if (val === undefined || val === null) return true; //
|
|
51
|
+
if (val === undefined || val === null) return true; // default: enabled
|
|
46
52
|
if (typeof val === "string") {
|
|
47
53
|
return val === "1" || val.toLowerCase() === "true";
|
|
48
54
|
}
|
|
@@ -52,6 +58,18 @@ export function getQwenMode(config) {
|
|
|
52
58
|
* Get token storage path
|
|
53
59
|
*/
|
|
54
60
|
export function getTokenPath() {
|
|
61
|
+
return join(getQwenDir(), "oauth_creds.json");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get token lock path for multi-process refresh coordination
|
|
65
|
+
*/
|
|
66
|
+
export function getTokenLockPath() {
|
|
67
|
+
return join(getQwenDir(), "oauth_creds.lock");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get legacy token storage path used by old plugin versions
|
|
71
|
+
*/
|
|
72
|
+
export function getLegacyTokenPath() {
|
|
55
73
|
return join(getConfigDir(), "oauth_token.json");
|
|
56
74
|
}
|
|
57
75
|
/**
|
|
@@ -60,4 +78,4 @@ export function getTokenPath() {
|
|
|
60
78
|
export function getCacheDir() {
|
|
61
79
|
return join(homedir(), ".opencode", "cache");
|
|
62
80
|
}
|
|
63
|
-
//# sourceMappingURL=config.js.map
|
|
81
|
+
//# sourceMappingURL=config.js.map
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export declare const HTTP_STATUS: {
|
|
|
45
45
|
*/
|
|
46
46
|
export declare const PORTAL_HEADERS: {
|
|
47
47
|
readonly AUTH_TYPE: "X-DashScope-AuthType";
|
|
48
|
-
readonly AUTH_TYPE_VALUE: "
|
|
48
|
+
readonly AUTH_TYPE_VALUE: "qwen-oauth";
|
|
49
49
|
};
|
|
50
50
|
/** Device flow polling configuration */
|
|
51
51
|
export declare const DEVICE_FLOW: {
|
|
@@ -85,7 +85,7 @@ export declare const PLATFORM_OPENERS: {
|
|
|
85
85
|
};
|
|
86
86
|
/** OAuth authorization labels */
|
|
87
87
|
export declare const AUTH_LABELS: {
|
|
88
|
-
readonly OAUTH: "Qwen
|
|
88
|
+
readonly OAUTH: "Qwen Code (qwen.ai OAuth)";
|
|
89
89
|
readonly INSTRUCTIONS: "Visit the URL shown in your browser to complete authentication.";
|
|
90
90
|
};
|
|
91
91
|
/** OAuth verification URI parameters */
|
|
@@ -102,4 +102,4 @@ export declare const STREAM_CONFIG: {
|
|
|
102
102
|
/** Maximum buffer size for SSE pass-through mode (1MB) */
|
|
103
103
|
readonly MAX_BUFFER_SIZE: number;
|
|
104
104
|
};
|
|
105
|
-
//# sourceMappingURL=constants.d.ts.map
|
|
105
|
+
//# sourceMappingURL=constants.d.ts.map
|
package/dist/lib/constants.js
CHANGED
|
@@ -45,7 +45,7 @@ export const HTTP_STATUS = {
|
|
|
45
45
|
*/
|
|
46
46
|
export const PORTAL_HEADERS = {
|
|
47
47
|
AUTH_TYPE: "X-DashScope-AuthType",
|
|
48
|
-
AUTH_TYPE_VALUE: "
|
|
48
|
+
AUTH_TYPE_VALUE: "qwen-oauth",
|
|
49
49
|
};
|
|
50
50
|
/** Device flow polling configuration */
|
|
51
51
|
export const DEVICE_FLOW = {
|
|
@@ -95,11 +95,11 @@ export const VERIFICATION_URI = {
|
|
|
95
95
|
/** Full query parameter for Qwen Code client */
|
|
96
96
|
CLIENT_PARAM_VALUE: "client=qwen-code",
|
|
97
97
|
};
|
|
98
|
-
/** Token refresh buffer (refresh
|
|
99
|
-
export const TOKEN_REFRESH_BUFFER_MS =
|
|
98
|
+
/** Token refresh buffer (refresh 30 seconds before expiry) */
|
|
99
|
+
export const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; // 30 seconds
|
|
100
100
|
/** Stream processing configuration */
|
|
101
101
|
export const STREAM_CONFIG = {
|
|
102
102
|
/** Maximum buffer size for SSE pass-through mode (1MB) */
|
|
103
103
|
MAX_BUFFER_SIZE: 1024 * 1024,
|
|
104
104
|
};
|
|
105
|
-
//# sourceMappingURL=constants.js.map
|
|
105
|
+
//# sourceMappingURL=constants.js.map
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -68,7 +68,9 @@ export interface QwenTokenResponse {
|
|
|
68
68
|
export interface StoredTokenData {
|
|
69
69
|
access_token: string;
|
|
70
70
|
refresh_token: string;
|
|
71
|
-
|
|
71
|
+
token_type?: string;
|
|
72
|
+
expiry_date: number;
|
|
73
|
+
expires?: number;
|
|
72
74
|
resource_url?: string;
|
|
73
75
|
}
|
|
74
76
|
/**
|
|
@@ -86,6 +88,10 @@ export interface TokenSuccess {
|
|
|
86
88
|
*/
|
|
87
89
|
export interface TokenFailure {
|
|
88
90
|
type: "failed";
|
|
91
|
+
status?: number;
|
|
92
|
+
error?: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
fatal?: boolean;
|
|
89
95
|
}
|
|
90
96
|
/**
|
|
91
97
|
* Token exchange pending result (device flow)
|
|
@@ -177,4 +183,4 @@ export interface ErrorResponse {
|
|
|
177
183
|
code?: string;
|
|
178
184
|
}
|
|
179
185
|
export type { Auth, Provider, Model };
|
|
180
|
-
//# sourceMappingURL=types.d.ts.map
|
|
186
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED