opencode-qwencode-auth 1.0.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 +198 -0
- package/README.pt-BR.md +198 -0
- package/index.ts +3 -0
- package/package.json +50 -0
- package/src/cli.ts +100 -0
- package/src/constants.ts +56 -0
- package/src/index.ts +217 -0
- package/src/plugin/auth.ts +114 -0
- package/src/plugin/client.ts +217 -0
- package/src/plugin/utils.ts +17 -0
- package/src/qwen/oauth.ts +266 -0
- package/src/types.ts +105 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qwen OAuth Device Flow Implementation
|
|
3
|
+
*
|
|
4
|
+
* Based on qwen-code's implementation (RFC 8628)
|
|
5
|
+
* Handles PKCE, device authorization, and token polling
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes, createHash, randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
import { QWEN_OAUTH_CONFIG } from '../constants.js';
|
|
11
|
+
import type { QwenCredentials } from '../types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Device authorization response from Qwen OAuth
|
|
15
|
+
*/
|
|
16
|
+
export interface DeviceAuthorizationResponse {
|
|
17
|
+
device_code: string;
|
|
18
|
+
user_code: string;
|
|
19
|
+
verification_uri: string;
|
|
20
|
+
verification_uri_complete: string;
|
|
21
|
+
expires_in: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Token response from Qwen OAuth
|
|
26
|
+
*/
|
|
27
|
+
export interface TokenResponse {
|
|
28
|
+
access_token: string;
|
|
29
|
+
refresh_token?: string;
|
|
30
|
+
token_type: string;
|
|
31
|
+
expires_in: number;
|
|
32
|
+
scope?: string;
|
|
33
|
+
resource_url?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate PKCE code verifier and challenge (RFC 7636)
|
|
38
|
+
*/
|
|
39
|
+
export function generatePKCE(): { verifier: string; challenge: string } {
|
|
40
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
41
|
+
const challenge = createHash('sha256')
|
|
42
|
+
.update(verifier)
|
|
43
|
+
.digest('base64url');
|
|
44
|
+
|
|
45
|
+
return { verifier, challenge };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate random state for OAuth
|
|
50
|
+
*/
|
|
51
|
+
export function generateState(): string {
|
|
52
|
+
return randomBytes(16).toString('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Convert object to URL-encoded form data
|
|
57
|
+
*/
|
|
58
|
+
function objectToUrlEncoded(data: Record<string, string>): string {
|
|
59
|
+
return Object.keys(data)
|
|
60
|
+
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
|
|
61
|
+
.join('&');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Request device authorization from Qwen OAuth
|
|
66
|
+
* Returns device_code, user_code, and verification URL
|
|
67
|
+
*/
|
|
68
|
+
export async function requestDeviceAuthorization(
|
|
69
|
+
codeChallenge: string
|
|
70
|
+
): Promise<DeviceAuthorizationResponse> {
|
|
71
|
+
const bodyData = {
|
|
72
|
+
client_id: QWEN_OAUTH_CONFIG.clientId,
|
|
73
|
+
scope: QWEN_OAUTH_CONFIG.scope,
|
|
74
|
+
code_challenge: codeChallenge,
|
|
75
|
+
code_challenge_method: 'S256',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const response = await fetch(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
82
|
+
Accept: 'application/json',
|
|
83
|
+
'x-request-id': randomUUID(),
|
|
84
|
+
},
|
|
85
|
+
body: objectToUrlEncoded(bodyData),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const errorData = await response.text();
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Device authorization failed: ${response.status} ${response.statusText}. Response: ${errorData}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const result = await response.json() as DeviceAuthorizationResponse;
|
|
96
|
+
|
|
97
|
+
if (!result.device_code || !result.user_code) {
|
|
98
|
+
throw new Error('Invalid device authorization response');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Poll for device token after user authorization
|
|
106
|
+
* Returns null if still pending, throws on error
|
|
107
|
+
*/
|
|
108
|
+
export async function pollDeviceToken(
|
|
109
|
+
deviceCode: string,
|
|
110
|
+
codeVerifier: string
|
|
111
|
+
): Promise<TokenResponse | null> {
|
|
112
|
+
const bodyData = {
|
|
113
|
+
grant_type: QWEN_OAUTH_CONFIG.grantType,
|
|
114
|
+
client_id: QWEN_OAUTH_CONFIG.clientId,
|
|
115
|
+
device_code: deviceCode,
|
|
116
|
+
code_verifier: codeVerifier,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
123
|
+
Accept: 'application/json',
|
|
124
|
+
},
|
|
125
|
+
body: objectToUrlEncoded(bodyData),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
const responseText = await response.text();
|
|
130
|
+
|
|
131
|
+
// Try to parse error response
|
|
132
|
+
try {
|
|
133
|
+
const errorData = JSON.parse(responseText) as { error?: string; error_description?: string };
|
|
134
|
+
|
|
135
|
+
// RFC 8628: authorization_pending means user hasn't authorized yet
|
|
136
|
+
if (response.status === 400 && errorData.error === 'authorization_pending') {
|
|
137
|
+
return null; // Still pending
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// RFC 8628: slow_down means we should increase poll interval
|
|
141
|
+
if (response.status === 429 && errorData.error === 'slow_down') {
|
|
142
|
+
return null; // Still pending, but should slow down
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || responseText}`
|
|
147
|
+
);
|
|
148
|
+
} catch (parseError) {
|
|
149
|
+
if (parseError instanceof SyntaxError) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
throw parseError;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (await response.json()) as TokenResponse;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Convert token response to QwenCredentials format
|
|
163
|
+
*/
|
|
164
|
+
export function tokenResponseToCredentials(tokenResponse: TokenResponse): QwenCredentials {
|
|
165
|
+
return {
|
|
166
|
+
accessToken: tokenResponse.access_token,
|
|
167
|
+
tokenType: tokenResponse.token_type || 'Bearer',
|
|
168
|
+
refreshToken: tokenResponse.refresh_token,
|
|
169
|
+
resourceUrl: tokenResponse.resource_url,
|
|
170
|
+
expiryDate: Date.now() + tokenResponse.expires_in * 1000,
|
|
171
|
+
scope: tokenResponse.scope,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refresh the access token using refresh_token grant
|
|
177
|
+
*/
|
|
178
|
+
export async function refreshAccessToken(refreshToken: string): Promise<QwenCredentials> {
|
|
179
|
+
const bodyData = {
|
|
180
|
+
grant_type: 'refresh_token',
|
|
181
|
+
refresh_token: refreshToken,
|
|
182
|
+
client_id: QWEN_OAUTH_CONFIG.clientId,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: {
|
|
188
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
189
|
+
Accept: 'application/json',
|
|
190
|
+
},
|
|
191
|
+
body: objectToUrlEncoded(bodyData),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
const errorText = await response.text();
|
|
196
|
+
throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const data = await response.json() as TokenResponse;
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
accessToken: data.access_token,
|
|
203
|
+
tokenType: data.token_type || 'Bearer',
|
|
204
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
205
|
+
resourceUrl: data.resource_url,
|
|
206
|
+
expiryDate: Date.now() + data.expires_in * 1000,
|
|
207
|
+
scope: data.scope,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if credentials are expired
|
|
213
|
+
* Uses 30 second buffer like qwen-code
|
|
214
|
+
*/
|
|
215
|
+
export function isCredentialsExpired(credentials: QwenCredentials): boolean {
|
|
216
|
+
if (!credentials.expiryDate) {
|
|
217
|
+
return false; // Assume not expired if no expiry time
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Add 30 second buffer (same as qwen-code)
|
|
221
|
+
return Date.now() > credentials.expiryDate - 30 * 1000;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Perform full device authorization flow
|
|
226
|
+
* Opens browser for user to authorize, polls for token
|
|
227
|
+
*/
|
|
228
|
+
export async function performDeviceAuthFlow(
|
|
229
|
+
onVerificationUrl: (url: string, userCode: string) => void,
|
|
230
|
+
pollIntervalMs = 2000,
|
|
231
|
+
timeoutMs = 5 * 60 * 1000
|
|
232
|
+
): Promise<QwenCredentials> {
|
|
233
|
+
// Generate PKCE
|
|
234
|
+
const { verifier, challenge } = generatePKCE();
|
|
235
|
+
|
|
236
|
+
// Request device authorization
|
|
237
|
+
const deviceAuth = await requestDeviceAuthorization(challenge);
|
|
238
|
+
|
|
239
|
+
// Notify caller of verification URL
|
|
240
|
+
onVerificationUrl(deviceAuth.verification_uri_complete, deviceAuth.user_code);
|
|
241
|
+
|
|
242
|
+
// Poll for token
|
|
243
|
+
const startTime = Date.now();
|
|
244
|
+
let interval = pollIntervalMs;
|
|
245
|
+
|
|
246
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const tokenResponse = await pollDeviceToken(deviceAuth.device_code, verifier);
|
|
251
|
+
|
|
252
|
+
if (tokenResponse) {
|
|
253
|
+
return tokenResponseToCredentials(tokenResponse);
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// Check if we should slow down
|
|
257
|
+
if (error instanceof Error && error.message.includes('slow_down')) {
|
|
258
|
+
interval = Math.min(interval * 1.5, 10000); // Increase interval, max 10s
|
|
259
|
+
} else {
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
throw new Error('Device authorization timeout');
|
|
266
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type Definitions for Qwen Auth Plugin
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { QWEN_MODELS } from './constants.js';
|
|
6
|
+
|
|
7
|
+
// ============================================
|
|
8
|
+
// Credentials Types
|
|
9
|
+
// ============================================
|
|
10
|
+
|
|
11
|
+
export interface QwenCredentials {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
tokenType?: string; // "Bearer"
|
|
14
|
+
refreshToken?: string;
|
|
15
|
+
resourceUrl?: string; // "portal.qwen.ai" - base URL da API
|
|
16
|
+
expiryDate?: number; // timestamp em ms (formato qwen-code)
|
|
17
|
+
scope?: string; // "openid profile email"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface QwenOAuthState {
|
|
21
|
+
codeVerifier: string;
|
|
22
|
+
state: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// API Types
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
export type QwenModelId = keyof typeof QWEN_MODELS;
|
|
30
|
+
|
|
31
|
+
export interface ChatMessage {
|
|
32
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
33
|
+
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
|
|
34
|
+
name?: string;
|
|
35
|
+
tool_calls?: Array<{
|
|
36
|
+
id: string;
|
|
37
|
+
type: 'function';
|
|
38
|
+
function: { name: string; arguments: string };
|
|
39
|
+
}>;
|
|
40
|
+
tool_call_id?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ChatCompletionRequest {
|
|
44
|
+
model: string;
|
|
45
|
+
messages: ChatMessage[];
|
|
46
|
+
temperature?: number;
|
|
47
|
+
top_p?: number;
|
|
48
|
+
max_tokens?: number;
|
|
49
|
+
stream?: boolean;
|
|
50
|
+
tools?: Array<{
|
|
51
|
+
type: 'function';
|
|
52
|
+
function: {
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
parameters: Record<string, unknown>;
|
|
56
|
+
};
|
|
57
|
+
}>;
|
|
58
|
+
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ChatCompletionResponse {
|
|
62
|
+
id: string;
|
|
63
|
+
object: 'chat.completion';
|
|
64
|
+
created: number;
|
|
65
|
+
model: string;
|
|
66
|
+
choices: Array<{
|
|
67
|
+
index: number;
|
|
68
|
+
message: {
|
|
69
|
+
role: 'assistant';
|
|
70
|
+
content: string | null;
|
|
71
|
+
tool_calls?: Array<{
|
|
72
|
+
id: string;
|
|
73
|
+
type: 'function';
|
|
74
|
+
function: { name: string; arguments: string };
|
|
75
|
+
}>;
|
|
76
|
+
};
|
|
77
|
+
finish_reason: 'stop' | 'length' | 'tool_calls' | null;
|
|
78
|
+
}>;
|
|
79
|
+
usage?: {
|
|
80
|
+
prompt_tokens: number;
|
|
81
|
+
completion_tokens: number;
|
|
82
|
+
total_tokens: number;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface StreamChunk {
|
|
87
|
+
id: string;
|
|
88
|
+
object: 'chat.completion.chunk';
|
|
89
|
+
created: number;
|
|
90
|
+
model: string;
|
|
91
|
+
choices: Array<{
|
|
92
|
+
index: number;
|
|
93
|
+
delta: {
|
|
94
|
+
role?: 'assistant';
|
|
95
|
+
content?: string;
|
|
96
|
+
tool_calls?: Array<{
|
|
97
|
+
index: number;
|
|
98
|
+
id?: string;
|
|
99
|
+
type?: 'function';
|
|
100
|
+
function?: { name?: string; arguments?: string };
|
|
101
|
+
}>;
|
|
102
|
+
};
|
|
103
|
+
finish_reason: 'stop' | 'length' | 'tool_calls' | null;
|
|
104
|
+
}>;
|
|
105
|
+
}
|