@oevortex/opencode-qwen-auth 0.1.0
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/LICENSE +21 -0
- package/README.md +220 -0
- package/index.ts +82 -0
- package/package.json +40 -0
- package/src/constants.ts +43 -0
- package/src/global.d.ts +257 -0
- package/src/models.ts +148 -0
- package/src/plugin/auth.ts +151 -0
- package/src/plugin/browser.ts +126 -0
- package/src/plugin/fetch-wrapper.ts +460 -0
- package/src/plugin/logger.ts +111 -0
- package/src/plugin/server.ts +364 -0
- package/src/plugin/token.ts +225 -0
- package/src/plugin.ts +444 -0
- package/src/qwen/oauth.ts +271 -0
- package/src/qwen/thinking-parser.ts +190 -0
- package/src/types.ts +292 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main Qwen OAuth Plugin for OpenCode
|
|
3
|
+
* Provides authentication with Qwen/Alibaba Cloud DashScope API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
QWEN_PROVIDER_ID,
|
|
8
|
+
QWEN_CALLBACK_PORT,
|
|
9
|
+
QWEN_REDIRECT_URI,
|
|
10
|
+
QWEN_OAUTH_BASE_URL,
|
|
11
|
+
QWEN_OAUTH_CLIENT_ID,
|
|
12
|
+
QWEN_PORTAL_BASE_URL,
|
|
13
|
+
} from "./constants";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
AuthDetails,
|
|
17
|
+
OAuthAuthDetails,
|
|
18
|
+
PluginContext,
|
|
19
|
+
PluginResult,
|
|
20
|
+
GetAuth,
|
|
21
|
+
Provider,
|
|
22
|
+
LoaderResult,
|
|
23
|
+
TokenExchangeResult,
|
|
24
|
+
AuthorizeResult,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
isOAuthAuth,
|
|
29
|
+
accessTokenExpired,
|
|
30
|
+
parseRefreshParts,
|
|
31
|
+
formatRefreshParts,
|
|
32
|
+
} from "./plugin/auth";
|
|
33
|
+
import { refreshAccessToken } from "./plugin/token";
|
|
34
|
+
import { createQwenFetch } from "./plugin/fetch-wrapper";
|
|
35
|
+
import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
|
36
|
+
import { openBrowser, isHeadless } from "./plugin/browser";
|
|
37
|
+
import { createLogger, initLogger } from "./plugin/logger";
|
|
38
|
+
|
|
39
|
+
const log = createLogger("plugin");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate a random state string for OAuth CSRF protection.
|
|
43
|
+
*/
|
|
44
|
+
function generateState(): string {
|
|
45
|
+
const array = new Uint8Array(32);
|
|
46
|
+
crypto.getRandomValues(array);
|
|
47
|
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
48
|
+
"",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate the OAuth authorization URL for Qwen.
|
|
54
|
+
*/
|
|
55
|
+
function generateAuthorizationUrl(state: string, redirectUri: string): string {
|
|
56
|
+
const params = new URLSearchParams({
|
|
57
|
+
response_type: "code",
|
|
58
|
+
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
59
|
+
redirect_uri: redirectUri,
|
|
60
|
+
state: state,
|
|
61
|
+
scope: "openid profile",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/authorize?${params.toString()}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Exchange authorization code for tokens.
|
|
69
|
+
*/
|
|
70
|
+
async function exchangeCodeForTokens(
|
|
71
|
+
code: string,
|
|
72
|
+
state: string,
|
|
73
|
+
redirectUri: string,
|
|
74
|
+
): Promise<TokenExchangeResult> {
|
|
75
|
+
try {
|
|
76
|
+
const tokenUrl = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
|
77
|
+
|
|
78
|
+
const bodyData = new URLSearchParams({
|
|
79
|
+
grant_type: "authorization_code",
|
|
80
|
+
code: code,
|
|
81
|
+
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
82
|
+
redirect_uri: redirectUri,
|
|
83
|
+
state: state,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const response = await fetch(tokenUrl, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
90
|
+
Accept: "application/json",
|
|
91
|
+
"User-Agent":
|
|
92
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
93
|
+
},
|
|
94
|
+
body: bodyData.toString(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const errorText = await response.text();
|
|
99
|
+
log.error("Token exchange failed", {
|
|
100
|
+
status: response.status,
|
|
101
|
+
error: errorText.slice(0, 200),
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
type: "failed",
|
|
105
|
+
error: `Token exchange failed: ${response.status} ${response.statusText}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const tokenData = (await response.json()) as {
|
|
110
|
+
access_token: string;
|
|
111
|
+
refresh_token: string;
|
|
112
|
+
expires_in: number;
|
|
113
|
+
token_type?: string;
|
|
114
|
+
error?: string;
|
|
115
|
+
error_description?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (tokenData.error) {
|
|
119
|
+
return {
|
|
120
|
+
type: "failed",
|
|
121
|
+
error: `${tokenData.error}: ${tokenData.error_description || "Unknown error"}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
|
126
|
+
|
|
127
|
+
// Build refresh string with resource URL
|
|
128
|
+
const refreshParts = {
|
|
129
|
+
refreshToken: tokenData.refresh_token,
|
|
130
|
+
resourceUrl: QWEN_PORTAL_BASE_URL,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
type: "success",
|
|
135
|
+
access: tokenData.access_token,
|
|
136
|
+
refresh: formatRefreshParts(refreshParts),
|
|
137
|
+
expires: expiresAt,
|
|
138
|
+
resourceUrl: QWEN_PORTAL_BASE_URL,
|
|
139
|
+
};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
142
|
+
log.error("Token exchange exception", { error: errorMessage });
|
|
143
|
+
return {
|
|
144
|
+
type: "failed",
|
|
145
|
+
error: `Token exchange failed: ${errorMessage}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Perform OAuth authentication flow.
|
|
152
|
+
*/
|
|
153
|
+
async function authenticateWithOAuth(
|
|
154
|
+
client: PluginContext["client"],
|
|
155
|
+
): Promise<TokenExchangeResult> {
|
|
156
|
+
const headless = isHeadless();
|
|
157
|
+
const state = generateState();
|
|
158
|
+
const redirectUri = QWEN_REDIRECT_URI;
|
|
159
|
+
const authUrl = generateAuthorizationUrl(state, redirectUri);
|
|
160
|
+
|
|
161
|
+
let listener: OAuthListener | null = null;
|
|
162
|
+
|
|
163
|
+
// Try to start callback listener (non-headless only)
|
|
164
|
+
if (!headless) {
|
|
165
|
+
try {
|
|
166
|
+
listener = await startOAuthListener(QWEN_CALLBACK_PORT);
|
|
167
|
+
log.info("OAuth callback server started", { port: listener.port });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
log.warn("Could not start callback listener", {
|
|
170
|
+
error: error instanceof Error ? error.message : String(error),
|
|
171
|
+
});
|
|
172
|
+
await client.tui.showToast({
|
|
173
|
+
body: {
|
|
174
|
+
message:
|
|
175
|
+
"Couldn't start callback listener. Falling back to manual copy/paste.",
|
|
176
|
+
variant: "warning",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Try to open browser
|
|
183
|
+
if (!headless) {
|
|
184
|
+
try {
|
|
185
|
+
await openBrowser(authUrl);
|
|
186
|
+
log.info("Browser opened for authentication");
|
|
187
|
+
} catch {
|
|
188
|
+
await client.tui.showToast({
|
|
189
|
+
body: {
|
|
190
|
+
message:
|
|
191
|
+
"Could not open browser automatically. Please copy/paste the URL.",
|
|
192
|
+
variant: "warning",
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Handle authentication based on mode
|
|
199
|
+
if (listener) {
|
|
200
|
+
// Automatic mode - wait for callback
|
|
201
|
+
await client.tui.showToast({
|
|
202
|
+
body: {
|
|
203
|
+
message: "Waiting for browser authentication...",
|
|
204
|
+
variant: "info",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const callbackUrl = await listener.waitForCallback();
|
|
210
|
+
const code = callbackUrl.searchParams.get("code");
|
|
211
|
+
const returnedState = callbackUrl.searchParams.get("state");
|
|
212
|
+
|
|
213
|
+
if (!code) {
|
|
214
|
+
return {
|
|
215
|
+
type: "failed",
|
|
216
|
+
error: "Missing authorization code in callback",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (returnedState !== state) {
|
|
221
|
+
return {
|
|
222
|
+
type: "failed",
|
|
223
|
+
error: "State mismatch - possible CSRF attack",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return exchangeCodeForTokens(code, state, redirectUri);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return {
|
|
230
|
+
type: "failed",
|
|
231
|
+
error:
|
|
232
|
+
error instanceof Error ? error.message : "Unknown callback error",
|
|
233
|
+
};
|
|
234
|
+
} finally {
|
|
235
|
+
try {
|
|
236
|
+
await listener.close();
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// Manual mode - prompt for callback URL
|
|
241
|
+
console.log("\n=== Qwen OAuth Setup ===");
|
|
242
|
+
console.log(`Open this URL in your browser:\n${authUrl}\n`);
|
|
243
|
+
|
|
244
|
+
// Dynamic import for readline to avoid module resolution issues
|
|
245
|
+
const readlineModule = await import("readline");
|
|
246
|
+
const rl = readlineModule.createInterface({
|
|
247
|
+
input: process.stdin,
|
|
248
|
+
output: process.stdout,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const question = (prompt: string): Promise<string> => {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
rl.question(prompt, (answer) => {
|
|
254
|
+
resolve(answer as string);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const callbackUrlStr = await question(
|
|
261
|
+
"Paste the full redirect URL here: ",
|
|
262
|
+
);
|
|
263
|
+
const callbackUrl = new URL(callbackUrlStr);
|
|
264
|
+
const code = callbackUrl.searchParams.get("code");
|
|
265
|
+
const returnedState = callbackUrl.searchParams.get("state");
|
|
266
|
+
|
|
267
|
+
if (!code) {
|
|
268
|
+
return {
|
|
269
|
+
type: "failed",
|
|
270
|
+
error: "Missing authorization code in callback URL",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (returnedState !== state) {
|
|
275
|
+
return {
|
|
276
|
+
type: "failed",
|
|
277
|
+
error: "State mismatch - possible CSRF attack",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return exchangeCodeForTokens(code, state, redirectUri);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return {
|
|
284
|
+
type: "failed",
|
|
285
|
+
error:
|
|
286
|
+
error instanceof Error
|
|
287
|
+
? error.message
|
|
288
|
+
: "Failed to parse callback URL",
|
|
289
|
+
};
|
|
290
|
+
} finally {
|
|
291
|
+
rl.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Main Qwen OAuth Plugin export.
|
|
298
|
+
*
|
|
299
|
+
* This plugin provides:
|
|
300
|
+
* - OAuth authentication with Qwen/Alibaba Cloud
|
|
301
|
+
* - Automatic token refresh
|
|
302
|
+
* - Request handling with proper authentication headers
|
|
303
|
+
*/
|
|
304
|
+
export async function QwenOAuthPlugin({
|
|
305
|
+
client,
|
|
306
|
+
}: PluginContext): Promise<PluginResult> {
|
|
307
|
+
// Initialize logger with plugin client
|
|
308
|
+
initLogger(client);
|
|
309
|
+
log.info("Qwen OAuth Plugin initialized");
|
|
310
|
+
|
|
311
|
+
// Cache for getAuth function
|
|
312
|
+
let cachedGetAuth: GetAuth | null = null;
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
auth: {
|
|
316
|
+
provider: QWEN_PROVIDER_ID,
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Loader function called when the provider is used.
|
|
320
|
+
* Returns configuration for API calls.
|
|
321
|
+
*/
|
|
322
|
+
loader: async (
|
|
323
|
+
getAuth: GetAuth,
|
|
324
|
+
provider: Provider,
|
|
325
|
+
): Promise<LoaderResult | Record<string, unknown>> => {
|
|
326
|
+
cachedGetAuth = getAuth;
|
|
327
|
+
|
|
328
|
+
const auth = await getAuth();
|
|
329
|
+
|
|
330
|
+
if (!isOAuthAuth(auth)) {
|
|
331
|
+
// Not OAuth auth - return empty config
|
|
332
|
+
return {};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
log.debug("Loading Qwen provider configuration");
|
|
336
|
+
|
|
337
|
+
// Set cost to 0 for all models (free with OAuth)
|
|
338
|
+
if (provider.models) {
|
|
339
|
+
for (const model of Object.values(provider.models)) {
|
|
340
|
+
if (model) {
|
|
341
|
+
model.cost = { input: 0, output: 0 };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Ensure we have a valid access token
|
|
347
|
+
let currentAuth = auth;
|
|
348
|
+
if (accessTokenExpired(currentAuth)) {
|
|
349
|
+
log.info("Access token expired, refreshing...");
|
|
350
|
+
const refreshed = await refreshAccessToken(currentAuth, client);
|
|
351
|
+
if (refreshed) {
|
|
352
|
+
currentAuth = refreshed;
|
|
353
|
+
|
|
354
|
+
// Save refreshed auth
|
|
355
|
+
try {
|
|
356
|
+
await client.auth.set({
|
|
357
|
+
path: { id: QWEN_PROVIDER_ID },
|
|
358
|
+
body: {
|
|
359
|
+
type: "oauth",
|
|
360
|
+
access: refreshed.access,
|
|
361
|
+
refresh: refreshed.refresh,
|
|
362
|
+
expires: refreshed.expires,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
} catch (error) {
|
|
366
|
+
log.warn("Failed to save refreshed auth", {
|
|
367
|
+
error: error instanceof Error ? error.message : String(error),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Get base URL from auth
|
|
374
|
+
const parts = parseRefreshParts(currentAuth.refresh);
|
|
375
|
+
let baseUrl = parts.resourceUrl || QWEN_PORTAL_BASE_URL;
|
|
376
|
+
if (!baseUrl.endsWith("/v1")) {
|
|
377
|
+
baseUrl = baseUrl.replace(/\/+$/, "") + "/v1";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Create fetch wrapper with auth handling
|
|
381
|
+
const qwenFetch = createQwenFetch(getAuth, client);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
apiKey: "", // Empty - using OAuth
|
|
385
|
+
baseUrl: baseUrl,
|
|
386
|
+
fetch: qwenFetch,
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Authentication methods available to the user.
|
|
392
|
+
*/
|
|
393
|
+
methods: [
|
|
394
|
+
{
|
|
395
|
+
label: "OAuth with Qwen (Alibaba Cloud)",
|
|
396
|
+
type: "oauth",
|
|
397
|
+
authorize: async (): Promise<AuthorizeResult> => {
|
|
398
|
+
const result = await authenticateWithOAuth(client);
|
|
399
|
+
|
|
400
|
+
if (result.type === "failed") {
|
|
401
|
+
await client.tui.showToast({
|
|
402
|
+
body: {
|
|
403
|
+
message: `Authentication failed: ${result.error}`,
|
|
404
|
+
variant: "error",
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
await client.tui.showToast({
|
|
409
|
+
body: {
|
|
410
|
+
message: "Successfully authenticated with Qwen!",
|
|
411
|
+
variant: "success",
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
url: "",
|
|
418
|
+
instructions:
|
|
419
|
+
result.type === "success"
|
|
420
|
+
? "Authentication complete!"
|
|
421
|
+
: result.error || "Authentication failed",
|
|
422
|
+
method: "auto",
|
|
423
|
+
callback: async () => result,
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
label: "Manually enter API Key",
|
|
429
|
+
type: "api",
|
|
430
|
+
prompts: [
|
|
431
|
+
{
|
|
432
|
+
type: "text",
|
|
433
|
+
message: "Enter your DashScope API Key",
|
|
434
|
+
key: "apiKey",
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Default export for convenience
|
|
444
|
+
export default QwenOAuthPlugin;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qwen OAuth Manager
|
|
3
|
+
* Handles OAuth token management for Qwen Code API
|
|
4
|
+
* Based on revibe/core/llm/backend/qwen/oauth.py
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
QWEN_DIR,
|
|
13
|
+
QWEN_CREDENTIAL_FILENAME,
|
|
14
|
+
QWEN_OAUTH_TOKEN_ENDPOINT,
|
|
15
|
+
QWEN_OAUTH_CLIENT_ID,
|
|
16
|
+
QWEN_DEFAULT_BASE_URL,
|
|
17
|
+
TOKEN_REFRESH_BUFFER_MS,
|
|
18
|
+
HTTP_OK,
|
|
19
|
+
} from "../constants";
|
|
20
|
+
|
|
21
|
+
import type { QwenOAuthCredentials, QwenTokenResponse } from "../types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the path to the cached OAuth credentials file.
|
|
25
|
+
*/
|
|
26
|
+
export function getQwenCachedCredentialPath(customPath?: string): string {
|
|
27
|
+
if (customPath) {
|
|
28
|
+
if (customPath.startsWith("~/")) {
|
|
29
|
+
return join(homedir(), customPath.slice(2));
|
|
30
|
+
}
|
|
31
|
+
return customPath;
|
|
32
|
+
}
|
|
33
|
+
return join(homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert an object to URL-encoded form data string.
|
|
38
|
+
*/
|
|
39
|
+
function objectToUrlEncoded(data: Record<string, string>): string {
|
|
40
|
+
return Object.entries(data)
|
|
41
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
42
|
+
.join("&");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Manages OAuth authentication for Qwen Code API.
|
|
47
|
+
*/
|
|
48
|
+
export class QwenOAuthManager {
|
|
49
|
+
private oauthPath?: string;
|
|
50
|
+
private credentials: QwenOAuthCredentials | null = null;
|
|
51
|
+
private refreshLock = false;
|
|
52
|
+
|
|
53
|
+
constructor(oauthPath?: string) {
|
|
54
|
+
this.oauthPath = oauthPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load OAuth credentials from the cached file.
|
|
59
|
+
*/
|
|
60
|
+
private loadCachedCredentials(): QwenOAuthCredentials {
|
|
61
|
+
const keyFile = getQwenCachedCredentialPath(this.oauthPath);
|
|
62
|
+
|
|
63
|
+
if (!existsSync(keyFile)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Qwen OAuth credentials not found at ${keyFile}. ` +
|
|
66
|
+
"Please login using the Qwen Code CLI first: qwen-code auth login"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const content = readFileSync(keyFile, "utf-8");
|
|
72
|
+
const data = JSON.parse(content) as Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
if (!data.access_token || !data.refresh_token || !data.expiry_date) {
|
|
75
|
+
throw new Error("Missing required fields in credentials file");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
access_token: data.access_token as string,
|
|
80
|
+
refresh_token: data.refresh_token as string,
|
|
81
|
+
token_type: (data.token_type as string) || "Bearer",
|
|
82
|
+
expiry_date: data.expiry_date as number,
|
|
83
|
+
resource_url: data.resource_url as string | undefined,
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof SyntaxError) {
|
|
87
|
+
throw new Error(`Invalid Qwen OAuth credentials file: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Refresh the OAuth access token.
|
|
95
|
+
*/
|
|
96
|
+
async refreshAccessToken(credentials: QwenOAuthCredentials): Promise<QwenOAuthCredentials> {
|
|
97
|
+
if (this.refreshLock) {
|
|
98
|
+
// Wait for ongoing refresh
|
|
99
|
+
while (this.refreshLock) {
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
101
|
+
}
|
|
102
|
+
return this.credentials || credentials;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.refreshLock = true;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
if (!credentials.refresh_token) {
|
|
109
|
+
throw new Error("No refresh token available in credentials.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const bodyData = {
|
|
113
|
+
grant_type: "refresh_token",
|
|
114
|
+
refresh_token: credentials.refresh_token,
|
|
115
|
+
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: {
|
|
121
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
122
|
+
"Accept": "application/json",
|
|
123
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
124
|
+
},
|
|
125
|
+
body: objectToUrlEncoded(bodyData),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (response.status !== HTTP_OK) {
|
|
129
|
+
const errorText = await response.text();
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Token refresh failed: ${response.status} ${response.statusText}. Response: ${errorText}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let tokenData: QwenTokenResponse;
|
|
136
|
+
try {
|
|
137
|
+
tokenData = (await response.json()) as QwenTokenResponse;
|
|
138
|
+
} catch {
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Token refresh failed: Invalid JSON response from OAuth endpoint. Response: ${text.slice(0, 200)}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (tokenData.error) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Token refresh failed: ${tokenData.error} - ${tokenData.error_description || "Unknown error"}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const newCredentials: QwenOAuthCredentials = {
|
|
152
|
+
access_token: tokenData.access_token,
|
|
153
|
+
token_type: tokenData.token_type || "Bearer",
|
|
154
|
+
refresh_token: tokenData.refresh_token || credentials.refresh_token,
|
|
155
|
+
expiry_date: Date.now() + tokenData.expires_in * 1000,
|
|
156
|
+
resource_url: credentials.resource_url,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Save refreshed credentials
|
|
160
|
+
this.saveCredentials(newCredentials);
|
|
161
|
+
this.credentials = newCredentials;
|
|
162
|
+
|
|
163
|
+
return newCredentials;
|
|
164
|
+
} finally {
|
|
165
|
+
this.refreshLock = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Save credentials to the cache file.
|
|
171
|
+
*/
|
|
172
|
+
private saveCredentials(credentials: QwenOAuthCredentials): void {
|
|
173
|
+
const filePath = getQwenCachedCredentialPath(this.oauthPath);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const dir = join(filePath, "..");
|
|
177
|
+
if (!existsSync(dir)) {
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
writeFileSync(
|
|
182
|
+
filePath,
|
|
183
|
+
JSON.stringify(
|
|
184
|
+
{
|
|
185
|
+
access_token: credentials.access_token,
|
|
186
|
+
refresh_token: credentials.refresh_token,
|
|
187
|
+
token_type: credentials.token_type,
|
|
188
|
+
expiry_date: credentials.expiry_date,
|
|
189
|
+
resource_url: credentials.resource_url,
|
|
190
|
+
},
|
|
191
|
+
null,
|
|
192
|
+
2
|
|
193
|
+
),
|
|
194
|
+
"utf-8"
|
|
195
|
+
);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// Continue with refreshed token in memory even if file write fails
|
|
198
|
+
console.warn(`Warning: Failed to save refreshed credentials: ${error}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if the access token is still valid.
|
|
204
|
+
*/
|
|
205
|
+
private isTokenValid(credentials: QwenOAuthCredentials): boolean {
|
|
206
|
+
if (!credentials.expiry_date) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Invalidate cached credentials to force a refresh on next request.
|
|
214
|
+
*/
|
|
215
|
+
invalidateCredentials(): void {
|
|
216
|
+
this.credentials = null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Ensure we have valid authentication credentials.
|
|
221
|
+
* @returns Tuple of [access_token, base_url]
|
|
222
|
+
*/
|
|
223
|
+
async ensureAuthenticated(forceRefresh = false): Promise<[string, string]> {
|
|
224
|
+
// Always reload credentials from file to pick up external updates
|
|
225
|
+
this.credentials = this.loadCachedCredentials();
|
|
226
|
+
|
|
227
|
+
if (forceRefresh || !this.isTokenValid(this.credentials)) {
|
|
228
|
+
this.credentials = await this.refreshAccessToken(this.credentials);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return [this.credentials.access_token, this.getBaseUrl(this.credentials)];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get the API base URL from credentials.
|
|
236
|
+
*/
|
|
237
|
+
private getBaseUrl(credentials: QwenOAuthCredentials): string {
|
|
238
|
+
let baseUrl = credentials.resource_url || QWEN_DEFAULT_BASE_URL;
|
|
239
|
+
|
|
240
|
+
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
|
|
241
|
+
baseUrl = `https://${baseUrl}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Remove trailing slashes and add /v1 if not present
|
|
245
|
+
baseUrl = baseUrl.replace(/\/+$/, "");
|
|
246
|
+
if (!baseUrl.endsWith("/v1")) {
|
|
247
|
+
baseUrl = `${baseUrl}/v1`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return baseUrl;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get the current credentials, refreshing if needed.
|
|
255
|
+
*/
|
|
256
|
+
async getCredentials(): Promise<QwenOAuthCredentials> {
|
|
257
|
+
await this.ensureAuthenticated();
|
|
258
|
+
if (!this.credentials) {
|
|
259
|
+
throw new Error("Failed to get credentials");
|
|
260
|
+
}
|
|
261
|
+
return this.credentials;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Check if OAuth credentials exist.
|
|
267
|
+
*/
|
|
268
|
+
export function hasQwenCredentials(customPath?: string): boolean {
|
|
269
|
+
const keyFile = getQwenCachedCredentialPath(customPath);
|
|
270
|
+
return existsSync(keyFile);
|
|
271
|
+
}
|