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 CHANGED
@@ -1,68 +1,90 @@
1
- /**
2
- * Alibaba Qwen OAuth Authentication Plugin for opencode
3
- *
4
- * Plugin don gian: chi xu ly OAuth login + tra apiKey/baseURL cho SDK.
5
- * SDK tu xu ly streaming, headers, 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 { createPKCE, requestDeviceCode, pollForToken, getApiBaseUrl, saveToken, refreshAccessToken, loadStoredToken } from "./lib/auth/auth.js";
11
- import { PROVIDER_ID, AUTH_LABELS, DEVICE_FLOW, DEFAULT_QWEN_BASE_URL } from "./lib/constants.js";
12
- import { logError, logInfo, LOGGING_ENABLED } from "./lib/logger.js";
13
- /**
14
- * Lay access token hop le tu SDK auth state, refresh neu het han
15
- * Dung getAuth() cua SDK thay vi doc file truc tiep
16
- *
17
- * @param getAuth - Ham lay auth state tu SDK
18
- * @returns access token hoac null
19
- */
20
- async function getValidAccessToken(getAuth) {
21
- const auth = await getAuth();
22
- if (!auth || auth.type !== "oauth") {
23
- return null;
24
- }
25
- let accessToken = auth.access;
26
- // Refresh neu het han (buffer 60 giay)
27
- if (accessToken && auth.expires && Date.now() > auth.expires - 60000 && auth.refresh) {
28
- try {
29
- const refreshResult = await refreshAccessToken(auth.refresh);
30
- if (refreshResult.type === "success") {
31
- accessToken = refreshResult.access;
32
- saveToken(refreshResult);
33
- }
34
- else {
35
- if (LOGGING_ENABLED) {
36
- logError("Token refresh failed");
37
- }
38
- accessToken = undefined;
39
- }
40
- }
41
- catch (e) {
42
- if (LOGGING_ENABLED) {
43
- logError("Token refresh error:", e);
44
- }
45
- accessToken = undefined;
46
- }
47
- }
48
- return accessToken ?? null;
49
- }
50
- /**
51
- * Lay base URL tu token luu tren disk (resource_url)
52
- * Fallback ve portal.qwen.ai/v1 neu khong co
53
- */
54
- function getBaseUrl() {
55
- try {
56
- const stored = loadStoredToken();
57
- if (stored?.resource_url) {
58
- return getApiBaseUrl(stored.resource_url);
59
- }
60
- }
61
- catch (_) {
62
- // Loi doc file, dung default
63
- }
64
- return getApiBaseUrl();
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: lay token + base URL, tra ve cho SDK
83
- * Pattern giong plugin tham chieu opencode-qwencode-auth
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 cho OAuth models (mien phi)
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
- return {
95
- apiKey: accessToken,
96
- baseURL: DEFAULT_QWEN_BASE_URL,
97
- };
98
- },
99
- methods: [
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
- // Hien thi user code
115
- console.log(`\nPlease visit: ${deviceAuth.verification_uri}`);
116
- console.log(`And enter code: ${deviceAuth.user_code}\n`);
117
- // URL xac thuc - SDK se tu mo browser khi method=auto
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
- while (Date.now() - startTime < expiresIn) {
131
- await new Promise(resolve => setTimeout(resolve, pollInterval + POLLING_MARGIN_MS));
132
- const result = await pollForToken(deviceAuth.device_code, pkce.verifier);
133
- if (result.type === "success") {
134
- saveToken(result);
135
- // Tra ve cho SDK luu auth state
136
- return {
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
- pollInterval = Math.min(pollInterval + 5000, maxInterval);
145
- continue;
146
- }
147
- if (result.type === "pending") {
148
- continue;
149
- }
150
- // denied, expired, failed -> dung lai
151
- return { type: "failed" };
152
- }
153
- console.error("[qwen-oauth-plugin] Device authorization timed out");
154
- return { type: "failed" };
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
- * Dang ky provider qwen-code voi danh sach model
163
- * Chi dang ky model ma Portal API (OAuth) chap nhan:
164
- * coder-model va vision-model (theo QWEN_OAUTH_ALLOWED_MODELS cua CLI goc)
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: { baseURL: "https://portal.qwen.ai/v1" },
172
- models: {
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
- reasoning: true,
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
- * Gui header DashScope giong CLI goc
196
- * X-DashScope-CacheControl: enable prompt caching, giam token tieu thu
197
- * X-DashScope-AuthType: xac dinh auth method cho server
198
- */
199
- "chat.headers": async (_input, output) => {
200
- try {
201
- if (output?.headers) {
202
- output.headers["X-DashScope-CacheControl"] = "enable";
203
- output.headers["X-DashScope-AuthType"] = "qwen-oauth";
204
- }
205
- }
206
- catch (_) { /* khong de loi hook lam treo request */ }
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
@@ -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, getConfigDir } from "../config.js";
4
+ import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
5
5
  import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
6
- // So lan retry toi da khi refresh token that bai
7
- const MAX_REFRESH_RETRIES = 2;
6
+ const MAX_REFRESH_RETRIES = 1;
8
7
  const REFRESH_RETRY_DELAY_MS = 1000;
9
- /**
10
- * Normalize and validate resource_url from OAuth response
11
- * @param resourceUrl - Resource URL from token response
12
- * @returns Normalized URL or undefined if invalid
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
- // Check required fields
44
- if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
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
- // Validate expires_in is positive
49
- if (json.expires_in <= 0) {
50
- logError(`invalid expires_in value in ${context}:`, json.expires_in);
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
- * Request device authorization code
57
- * @param pkce - PKCE challenge/verifier pair
58
- * @returns Device authorization response with user code and verification URL
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 fetch(QWEN_OAUTH.DEVICE_CODE_URL, {
217
+ const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
63
218
  method: "POST",
64
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
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 fetch(QWEN_OAUTH.TOKEN_URL, {
260
+ const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
112
261
  method: "POST",
113
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
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 error = json.error;
124
- // Handle expected errors
125
- if (error === "authorization_pending") {
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 (error === "slow_down") {
280
+ if (errorCode === "slow_down") {
129
281
  return { type: "slow_down" };
130
282
  }
131
- if (error === "expired_token") {
283
+ if (errorCode === "expired_token") {
132
284
  return { type: "expired" };
133
285
  }
134
- if (error === "access_denied") {
286
+ if (errorCode === "access_denied") {
135
287
  return { type: "denied" };
136
288
  }
137
- logError("token poll failed:", { status: res.status, json });
138
- return { type: "failed" };
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 { type: "failed" };
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, // Dynamic API base URL
329
+ resourceUrl: json.resource_url,
167
330
  };
168
331
  }
169
332
  catch (error) {
170
- logError("token poll error:", error);
171
- return { type: "failed" };
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 fetch(QWEN_OAUTH.TOKEN_URL, {
346
+ const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
182
347
  method: "POST",
183
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
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 { type: "failed", status: res.status };
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 { type: "failed" };
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
- logError("token refresh error:", error);
224
- return { type: "failed" };
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
- for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
235
- const result = await refreshAccessTokenOnce(refreshToken);
236
- if (result.type === "success") {
237
- return result;
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
- // Con retry thi cho delay roi thu lai
245
- if (attempt < MAX_REFRESH_RETRIES) {
246
- if (LOGGING_ENABLED) {
247
- logInfo(`Token refresh that bai, thu lai lan ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
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 data = JSON.parse(content);
275
- // Validate required fields
276
- if (!data.access_token || !data.refresh_token || typeof data.expires !== "number") {
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
- return data;
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 tokenPath = getTokenPath();
292
- if (existsSync(tokenPath)) {
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("Da xoa token cu, can dang nhap lai");
497
+ logWarn(`Deleted token file: ${tokenPath}`);
296
498
  }
297
499
  catch (error) {
298
- logError("Khong the xoa token file:", error);
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
- expires: tokenResult.expires,
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
- writeFileSync(tokenPath, JSON.stringify(tokenData, null, 2), {
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; // Khong co token, can dang nhap
529
+ return null;
350
530
  }
351
- // Token con hieu luc
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('http')) {
391
- logWarn('Invalid resource_url protocol, using default Portal API URL');
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
- // Add /v1 suffix if not already present
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('Constructed Portal API base URL from resource_url:', baseUrl);
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('Invalid resource_url format, using default Portal API URL:', error);
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('No resource_url provided, using default Portal API URL');
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
@@ -30,8 +30,8 @@ export function openBrowserUrl(url) {
30
30
  });
31
31
  }
32
32
  catch (error) {
33
- // Log canh bao de ho tro debug, user van co the mo URL thu cong
34
- console.warn("[qwen-oauth-plugin] Khong the mo trinh duyet:", error?.message || error);
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
@@ -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
@@ -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 to QWEN_MODE enabled
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
- // Ep kieu boolean chac chan, tranh string "false" bi truthy
49
+ // Ensure boolean type, avoid string "false" being truthy
44
50
  const val = config.qwenMode;
45
- if (val === undefined || val === null) return true; // mac dinh bat
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
@@ -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: "qwen_oauth";
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 Account (OAuth)";
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
@@ -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: "qwen_oauth",
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 5 minutes before expiry) */
99
- export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
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
@@ -68,7 +68,9 @@ export interface QwenTokenResponse {
68
68
  export interface StoredTokenData {
69
69
  access_token: string;
70
70
  refresh_token: string;
71
- expires: number;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-qwen-cli-auth",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Qwen OAuth authentication plugin for opencode - use your Qwen account instead of API keys",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",