opencode-qwencode-auth 1.2.0 → 1.3.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/package.json +4 -8
- package/src/constants.ts +15 -32
- package/src/errors.ts +1 -1
- package/src/index.ts +40 -80
- package/src/plugin/auth.ts +2 -74
- package/src/qwen/oauth.ts +13 -9
- package/src/types.ts +0 -93
- package/src/plugin/client.ts +0 -218
- package/src/plugin/utils.ts +0 -17
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-qwencode-auth",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Qwen OAuth authentication plugin for OpenCode - Access Qwen AI models (Coder,
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Qwen OAuth authentication plugin for OpenCode - Access Qwen AI models (Coder, Vision) with your qwen.ai account",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
"qwen",
|
|
15
15
|
"qwen-code",
|
|
16
16
|
"qwen3-coder",
|
|
17
|
-
"qwen3-
|
|
18
|
-
"
|
|
19
|
-
"qwen-flash",
|
|
17
|
+
"qwen3-vl-plus",
|
|
18
|
+
"vision-model",
|
|
20
19
|
"oauth",
|
|
21
20
|
"authentication",
|
|
22
21
|
"ai",
|
|
@@ -33,9 +32,6 @@
|
|
|
33
32
|
"bugs": {
|
|
34
33
|
"url": "https://github.com/gustavodiasdev/opencode-qwencode-auth/issues"
|
|
35
34
|
},
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"open": "^10.1.0"
|
|
38
|
-
},
|
|
39
35
|
"devDependencies": {
|
|
40
36
|
"@opencode-ai/plugin": "^1.1.48",
|
|
41
37
|
"@types/node": "^22.0.0",
|
package/src/constants.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Based on qwen-code implementation
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// Provider ID
|
|
6
|
+
// Provider ID
|
|
7
7
|
export const QWEN_PROVIDER_ID = 'qwen-code';
|
|
8
8
|
|
|
9
9
|
// OAuth Device Flow Endpoints (descobertos do qwen-code)
|
|
@@ -34,8 +34,8 @@ export const QWEN_API_CONFIG = {
|
|
|
34
34
|
// OAuth callback port (para futuro Device Flow no plugin)
|
|
35
35
|
export const CALLBACK_PORT = 14561;
|
|
36
36
|
|
|
37
|
-
// Available Qwen models through OAuth
|
|
38
|
-
//
|
|
37
|
+
// Available Qwen models through OAuth (portal.qwen.ai)
|
|
38
|
+
// Testados e confirmados funcionando via token OAuth
|
|
39
39
|
export const QWEN_MODELS = {
|
|
40
40
|
// --- Coding Models ---
|
|
41
41
|
'qwen3-coder-plus': {
|
|
@@ -56,40 +56,23 @@ export const QWEN_MODELS = {
|
|
|
56
56
|
reasoning: false,
|
|
57
57
|
cost: { input: 0, output: 0 },
|
|
58
58
|
},
|
|
59
|
-
// ---
|
|
60
|
-
'
|
|
61
|
-
id: '
|
|
62
|
-
name: '
|
|
63
|
-
contextWindow:
|
|
64
|
-
maxOutput: 65536,
|
|
65
|
-
description: '
|
|
59
|
+
// --- Alias Models (portal mapeia internamente) ---
|
|
60
|
+
'coder-model': {
|
|
61
|
+
id: 'coder-model',
|
|
62
|
+
name: 'Qwen Coder (auto)',
|
|
63
|
+
contextWindow: 1048576,
|
|
64
|
+
maxOutput: 65536,
|
|
65
|
+
description: 'Auto-routed coding model (maps to qwen3-coder-plus)',
|
|
66
66
|
reasoning: false,
|
|
67
67
|
cost: { input: 0, output: 0 },
|
|
68
68
|
},
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
maxOutput: 16384, // 16K tokens
|
|
74
|
-
description: 'Balanced model with thinking mode, good quality-speed tradeoff',
|
|
75
|
-
reasoning: true,
|
|
76
|
-
cost: { input: 0, output: 0 },
|
|
77
|
-
},
|
|
78
|
-
'qwen3-235b-a22b': {
|
|
79
|
-
id: 'qwen3-235b-a22b',
|
|
80
|
-
name: 'Qwen3 235B-A22B',
|
|
69
|
+
// --- Vision Model ---
|
|
70
|
+
'vision-model': {
|
|
71
|
+
id: 'vision-model',
|
|
72
|
+
name: 'Qwen VL Plus (vision)',
|
|
81
73
|
contextWindow: 131072, // 128K tokens
|
|
82
74
|
maxOutput: 32768, // 32K tokens
|
|
83
|
-
description: '
|
|
84
|
-
reasoning: true,
|
|
85
|
-
cost: { input: 0, output: 0 },
|
|
86
|
-
},
|
|
87
|
-
'qwen-flash': {
|
|
88
|
-
id: 'qwen-flash',
|
|
89
|
-
name: 'Qwen Flash',
|
|
90
|
-
contextWindow: 1048576, // 1M tokens
|
|
91
|
-
maxOutput: 8192, // 8K tokens
|
|
92
|
-
description: 'Ultra-fast and low-cost model for simple tasks',
|
|
75
|
+
description: 'Vision-language model (maps to qwen3-vl-plus), supports image input',
|
|
93
76
|
reasoning: false,
|
|
94
77
|
cost: { input: 0, output: 0 },
|
|
95
78
|
},
|
package/src/errors.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const REAUTH_HINT =
|
|
9
|
-
'Execute "
|
|
9
|
+
'Execute "opencode auth login" e selecione "Qwen Code (qwen.ai OAuth)" para autenticar.';
|
|
10
10
|
|
|
11
11
|
// ============================================
|
|
12
12
|
// Erro de Autenticação
|
package/src/index.ts
CHANGED
|
@@ -1,44 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenCode Qwen Auth Plugin
|
|
3
3
|
*
|
|
4
|
-
* Plugin de
|
|
5
|
-
* Implementa Device Flow (RFC 8628) para
|
|
4
|
+
* Plugin de autenticacao OAuth para Qwen, baseado no qwen-code.
|
|
5
|
+
* Implementa Device Flow (RFC 8628) para autenticacao.
|
|
6
|
+
*
|
|
7
|
+
* Provider: qwen-code -> portal.qwen.ai/v1
|
|
8
|
+
* Modelos: qwen3-coder-plus, qwen3-coder-flash, coder-model, vision-model
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
import { existsSync } from 'node:fs';
|
|
9
11
|
import { spawn } from 'node:child_process';
|
|
10
12
|
|
|
11
13
|
import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS } from './constants.js';
|
|
12
14
|
import type { QwenCredentials } from './types.js';
|
|
13
|
-
import {
|
|
14
|
-
loadCredentials,
|
|
15
|
-
saveCredentials,
|
|
16
|
-
getCredentialsPath,
|
|
17
|
-
isCredentialsExpired,
|
|
18
|
-
} from './plugin/auth.js';
|
|
15
|
+
import { saveCredentials } from './plugin/auth.js';
|
|
19
16
|
import {
|
|
20
17
|
generatePKCE,
|
|
21
18
|
requestDeviceAuthorization,
|
|
22
19
|
pollDeviceToken,
|
|
23
20
|
tokenResponseToCredentials,
|
|
24
21
|
refreshAccessToken,
|
|
22
|
+
SlowDownError,
|
|
25
23
|
} from './qwen/oauth.js';
|
|
26
24
|
import { logTechnicalDetail } from './errors.js';
|
|
27
|
-
export { QwenAuthError, QwenApiError } from './errors.js';
|
|
28
|
-
export type { AuthErrorKind } from './errors.js';
|
|
29
25
|
|
|
30
26
|
// ============================================
|
|
31
27
|
// Helpers
|
|
32
28
|
// ============================================
|
|
33
29
|
|
|
34
|
-
function getBaseUrl(resourceUrl?: string): string {
|
|
35
|
-
if (!resourceUrl) return QWEN_API_CONFIG.baseUrl;
|
|
36
|
-
if (resourceUrl.startsWith('http')) {
|
|
37
|
-
return resourceUrl.endsWith('/v1') ? resourceUrl : `${resourceUrl}/v1`;
|
|
38
|
-
}
|
|
39
|
-
return `https://${resourceUrl}/v1`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
30
|
function openBrowser(url: string): void {
|
|
43
31
|
try {
|
|
44
32
|
const platform = process.platform;
|
|
@@ -51,15 +39,32 @@ function openBrowser(url: string): void {
|
|
|
51
39
|
}
|
|
52
40
|
}
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
42
|
+
/** Obtem um access token valido (com refresh se necessario) */
|
|
43
|
+
async function getValidAccessToken(
|
|
44
|
+
getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>,
|
|
45
|
+
): Promise<string | null> {
|
|
46
|
+
const auth = await getAuth();
|
|
47
|
+
|
|
48
|
+
if (!auth || auth.type !== 'oauth') {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let accessToken = auth.access;
|
|
53
|
+
|
|
54
|
+
// Refresh se expirado (com margem de 60s)
|
|
55
|
+
if (accessToken && auth.expires && Date.now() > auth.expires - 60_000 && auth.refresh) {
|
|
56
|
+
try {
|
|
57
|
+
const refreshed = await refreshAccessToken(auth.refresh);
|
|
58
|
+
accessToken = refreshed.accessToken;
|
|
59
|
+
saveCredentials(refreshed);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
62
|
+
logTechnicalDetail(`Token refresh falhou: ${detail}`);
|
|
63
|
+
accessToken = undefined;
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
|
-
|
|
66
|
+
|
|
67
|
+
return accessToken ?? null;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
// ============================================
|
|
@@ -73,22 +78,8 @@ export const QwenAuthPlugin = async (_input: unknown) => {
|
|
|
73
78
|
|
|
74
79
|
loader: async (
|
|
75
80
|
getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>,
|
|
76
|
-
provider: { models?: Record<string, { cost?: { input: number; output: number } }> }
|
|
81
|
+
provider: { models?: Record<string, { cost?: { input: number; output: number } }> },
|
|
77
82
|
) => {
|
|
78
|
-
const auth = await getAuth();
|
|
79
|
-
|
|
80
|
-
// Se não é OAuth, tentar carregar credenciais do qwen-code
|
|
81
|
-
if (!auth || auth.type !== 'oauth') {
|
|
82
|
-
const creds = checkExistingCredentials();
|
|
83
|
-
if (creds) {
|
|
84
|
-
return {
|
|
85
|
-
apiKey: creds.accessToken,
|
|
86
|
-
baseURL: getBaseUrl(creds.resourceUrl),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
83
|
// Zerar custo dos modelos (gratuito via OAuth)
|
|
93
84
|
if (provider?.models) {
|
|
94
85
|
for (const model of Object.values(provider.models)) {
|
|
@@ -96,48 +87,18 @@ export const QwenAuthPlugin = async (_input: unknown) => {
|
|
|
96
87
|
}
|
|
97
88
|
}
|
|
98
89
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Refresh se expirado
|
|
102
|
-
if (accessToken && auth.expires && Date.now() > auth.expires - 30000 && auth.refresh) {
|
|
103
|
-
try {
|
|
104
|
-
const refreshed = await refreshAccessToken(auth.refresh);
|
|
105
|
-
accessToken = refreshed.accessToken;
|
|
106
|
-
saveCredentials(refreshed);
|
|
107
|
-
} catch (e) {
|
|
108
|
-
const detail = e instanceof Error ? e.message : String(e);
|
|
109
|
-
logTechnicalDetail(`Token refresh falhou: ${detail}`);
|
|
110
|
-
// Não continuar com token expirado - tentar fallback
|
|
111
|
-
accessToken = undefined;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Fallback para credenciais do qwen-code
|
|
116
|
-
if (!accessToken) {
|
|
117
|
-
const creds = checkExistingCredentials();
|
|
118
|
-
if (creds) {
|
|
119
|
-
accessToken = creds.accessToken;
|
|
120
|
-
} else {
|
|
121
|
-
console.warn(
|
|
122
|
-
'[Qwen] Token expirado e sem credenciais alternativas. ' +
|
|
123
|
-
'Execute "npx opencode-qwencode-auth" ou "qwen-code auth login" para re-autenticar.'
|
|
124
|
-
);
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
90
|
+
const accessToken = await getValidAccessToken(getAuth);
|
|
129
91
|
if (!accessToken) return null;
|
|
130
92
|
|
|
131
|
-
const creds = loadCredentials();
|
|
132
93
|
return {
|
|
133
94
|
apiKey: accessToken,
|
|
134
|
-
baseURL:
|
|
95
|
+
baseURL: QWEN_API_CONFIG.baseUrl,
|
|
135
96
|
};
|
|
136
97
|
},
|
|
137
98
|
|
|
138
99
|
methods: [
|
|
139
100
|
{
|
|
140
|
-
type: 'oauth',
|
|
101
|
+
type: 'oauth' as const,
|
|
141
102
|
label: 'Qwen Code (qwen.ai OAuth)',
|
|
142
103
|
authorize: async () => {
|
|
143
104
|
const { verifier, challenge } = generatePKCE();
|
|
@@ -150,7 +111,7 @@ export const QwenAuthPlugin = async (_input: unknown) => {
|
|
|
150
111
|
|
|
151
112
|
return {
|
|
152
113
|
url: deviceAuth.verification_uri_complete,
|
|
153
|
-
instructions: `
|
|
114
|
+
instructions: `Codigo: ${deviceAuth.user_code}`,
|
|
154
115
|
method: 'auto' as const,
|
|
155
116
|
callback: async () => {
|
|
156
117
|
const startTime = Date.now();
|
|
@@ -158,7 +119,7 @@ export const QwenAuthPlugin = async (_input: unknown) => {
|
|
|
158
119
|
let interval = 5000;
|
|
159
120
|
|
|
160
121
|
while (Date.now() - startTime < timeoutMs) {
|
|
161
|
-
await
|
|
122
|
+
await new Promise(resolve => setTimeout(resolve, interval + POLLING_MARGIN_MS));
|
|
162
123
|
|
|
163
124
|
try {
|
|
164
125
|
const tokenResponse = await pollDeviceToken(deviceAuth.device_code, verifier);
|
|
@@ -170,15 +131,14 @@ export const QwenAuthPlugin = async (_input: unknown) => {
|
|
|
170
131
|
return {
|
|
171
132
|
type: 'success' as const,
|
|
172
133
|
access: credentials.accessToken,
|
|
173
|
-
refresh: credentials.refreshToken
|
|
134
|
+
refresh: credentials.refreshToken ?? '',
|
|
174
135
|
expires: credentials.expiryDate || Date.now() + 3600000,
|
|
175
136
|
};
|
|
176
137
|
}
|
|
177
138
|
} catch (e) {
|
|
178
|
-
|
|
179
|
-
if (msg.includes('slow_down')) {
|
|
139
|
+
if (e instanceof SlowDownError) {
|
|
180
140
|
interval = Math.min(interval + 5000, 15000);
|
|
181
|
-
} else if (!
|
|
141
|
+
} else if (!(e instanceof Error) || !e.message.includes('authorization_pending')) {
|
|
182
142
|
return { type: 'failed' as const };
|
|
183
143
|
}
|
|
184
144
|
}
|
package/src/plugin/auth.ts
CHANGED
|
@@ -1,69 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Qwen Credentials Management
|
|
3
3
|
*
|
|
4
|
-
* Handles
|
|
4
|
+
* Handles saving credentials to ~/.qwen/oauth_creds.json
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import { existsSync,
|
|
9
|
+
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
10
|
|
|
11
11
|
import type { QwenCredentials } from '../types.js';
|
|
12
|
-
import { refreshAccessToken, isCredentialsExpired } from '../qwen/oauth.js';
|
|
13
|
-
import { logTechnicalDetail } from '../errors.js';
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
14
|
* Get the path to the credentials file
|
|
17
|
-
* Uses the same location as qwen-code for compatibility
|
|
18
15
|
*/
|
|
19
16
|
export function getCredentialsPath(): string {
|
|
20
17
|
const homeDir = homedir();
|
|
21
18
|
return join(homeDir, '.qwen', 'oauth_creds.json');
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
/**
|
|
25
|
-
* Get the OpenCode auth store path
|
|
26
|
-
*/
|
|
27
|
-
export function getOpenCodeAuthPath(): string {
|
|
28
|
-
const homeDir = homedir();
|
|
29
|
-
return join(homeDir, '.local', 'share', 'opencode', 'auth.json');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Load existing Qwen credentials if available
|
|
34
|
-
* Supports qwen-code format with expiry_date and resource_url
|
|
35
|
-
*/
|
|
36
|
-
export function loadCredentials(): QwenCredentials | null {
|
|
37
|
-
const credPath = getCredentialsPath();
|
|
38
|
-
|
|
39
|
-
if (!existsSync(credPath)) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const data = readFileSync(credPath, 'utf-8');
|
|
45
|
-
const parsed = JSON.parse(data);
|
|
46
|
-
|
|
47
|
-
// Handle qwen-code format and variations
|
|
48
|
-
if (parsed.access_token || parsed.accessToken) {
|
|
49
|
-
return {
|
|
50
|
-
accessToken: parsed.access_token || parsed.accessToken,
|
|
51
|
-
tokenType: parsed.token_type || parsed.tokenType || 'Bearer',
|
|
52
|
-
refreshToken: parsed.refresh_token || parsed.refreshToken,
|
|
53
|
-
resourceUrl: parsed.resource_url || parsed.resourceUrl,
|
|
54
|
-
// qwen-code uses expiry_date, fallback to expires_at for compatibility
|
|
55
|
-
expiryDate: parsed.expiry_date || parsed.expiresAt || parsed.expires_at,
|
|
56
|
-
scope: parsed.scope,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return null;
|
|
61
|
-
} catch (error) {
|
|
62
|
-
logTechnicalDetail(`Erro ao carregar credenciais: ${error}`);
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
21
|
/**
|
|
68
22
|
* Save credentials to file in qwen-code compatible format
|
|
69
23
|
*/
|
|
@@ -87,29 +41,3 @@ export function saveCredentials(credentials: QwenCredentials): void {
|
|
|
87
41
|
|
|
88
42
|
writeFileSync(credPath, JSON.stringify(data, null, 2));
|
|
89
43
|
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Get valid credentials, refreshing if necessary
|
|
93
|
-
*/
|
|
94
|
-
export async function getValidCredentials(): Promise<QwenCredentials | null> {
|
|
95
|
-
let credentials = loadCredentials();
|
|
96
|
-
|
|
97
|
-
if (!credentials) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (isCredentialsExpired(credentials) && credentials.refreshToken) {
|
|
102
|
-
try {
|
|
103
|
-
credentials = await refreshAccessToken(credentials.refreshToken);
|
|
104
|
-
saveCredentials(credentials);
|
|
105
|
-
} catch (error) {
|
|
106
|
-
logTechnicalDetail(`Falha no refresh: ${error}`);
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return credentials;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Re-export isCredentialsExpired for convenience
|
|
115
|
-
export { isCredentialsExpired } from '../qwen/oauth.js';
|
package/src/qwen/oauth.ts
CHANGED
|
@@ -11,6 +11,17 @@ import { QWEN_OAUTH_CONFIG } from '../constants.js';
|
|
|
11
11
|
import type { QwenCredentials } from '../types.js';
|
|
12
12
|
import { QwenAuthError, logTechnicalDetail } from '../errors.js';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Erro lançado quando o servidor pede slow_down (RFC 8628)
|
|
16
|
+
* O caller deve aumentar o intervalo de polling
|
|
17
|
+
*/
|
|
18
|
+
export class SlowDownError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('slow_down: server requested increased polling interval');
|
|
21
|
+
this.name = 'SlowDownError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/**
|
|
15
26
|
* Device authorization response from Qwen OAuth
|
|
16
27
|
*/
|
|
@@ -46,13 +57,6 @@ export function generatePKCE(): { verifier: string; challenge: string } {
|
|
|
46
57
|
return { verifier, challenge };
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
/**
|
|
50
|
-
* Generate random state for OAuth
|
|
51
|
-
*/
|
|
52
|
-
export function generateState(): string {
|
|
53
|
-
return randomBytes(16).toString('hex');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
60
|
/**
|
|
57
61
|
* Convert object to URL-encoded form data
|
|
58
62
|
*/
|
|
@@ -139,7 +143,7 @@ export async function pollDeviceToken(
|
|
|
139
143
|
|
|
140
144
|
// RFC 8628: slow_down means we should increase poll interval
|
|
141
145
|
if (response.status === 429 && errorData.error === 'slow_down') {
|
|
142
|
-
|
|
146
|
+
throw new SlowDownError();
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
throw new Error(
|
|
@@ -255,7 +259,7 @@ export async function performDeviceAuthFlow(
|
|
|
255
259
|
}
|
|
256
260
|
} catch (error) {
|
|
257
261
|
// Check if we should slow down
|
|
258
|
-
if (error instanceof
|
|
262
|
+
if (error instanceof SlowDownError) {
|
|
259
263
|
interval = Math.min(interval * 1.5, 10000); // Increase interval, max 10s
|
|
260
264
|
} else {
|
|
261
265
|
throw error;
|
package/src/types.ts
CHANGED
|
@@ -2,12 +2,6 @@
|
|
|
2
2
|
* Type Definitions for Qwen Auth Plugin
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { QWEN_MODELS } from './constants.js';
|
|
6
|
-
|
|
7
|
-
// ============================================
|
|
8
|
-
// Credentials Types
|
|
9
|
-
// ============================================
|
|
10
|
-
|
|
11
5
|
export interface QwenCredentials {
|
|
12
6
|
accessToken: string;
|
|
13
7
|
tokenType?: string; // "Bearer"
|
|
@@ -16,90 +10,3 @@ export interface QwenCredentials {
|
|
|
16
10
|
expiryDate?: number; // timestamp em ms (formato qwen-code)
|
|
17
11
|
scope?: string; // "openid profile email"
|
|
18
12
|
}
|
|
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
|
-
}
|
package/src/plugin/client.ts
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Qwen API Client
|
|
3
|
-
*
|
|
4
|
-
* OpenAI-compatible client for making API calls to Qwen
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { QWEN_API_CONFIG, QWEN_MODELS } from '../constants.js';
|
|
8
|
-
import type {
|
|
9
|
-
QwenCredentials,
|
|
10
|
-
ChatCompletionRequest,
|
|
11
|
-
ChatCompletionResponse,
|
|
12
|
-
StreamChunk
|
|
13
|
-
} from '../types.js';
|
|
14
|
-
import { getValidCredentials, loadCredentials, isCredentialsExpired } from './auth.js';
|
|
15
|
-
import { QwenAuthError, QwenApiError } from '../errors.js';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* QwenClient - Makes authenticated API calls to Qwen
|
|
19
|
-
*/
|
|
20
|
-
export class QwenClient {
|
|
21
|
-
private credentials: QwenCredentials | null = null;
|
|
22
|
-
private debug: boolean;
|
|
23
|
-
|
|
24
|
-
constructor(options: { debug?: boolean } = {}) {
|
|
25
|
-
this.debug = options.debug || process.env.OPENCODE_QWEN_DEBUG === '1';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Get the API base URL from credentials or fallback to default
|
|
30
|
-
*/
|
|
31
|
-
private getBaseUrl(): string {
|
|
32
|
-
if (this.credentials?.resourceUrl) {
|
|
33
|
-
// resourceUrl from qwen-code is just the host, need to add protocol and path
|
|
34
|
-
const resourceUrl = this.credentials.resourceUrl;
|
|
35
|
-
if (resourceUrl.startsWith('http')) {
|
|
36
|
-
return resourceUrl.endsWith('/v1') ? resourceUrl : `${resourceUrl}/v1`;
|
|
37
|
-
}
|
|
38
|
-
return `https://${resourceUrl}/v1`;
|
|
39
|
-
}
|
|
40
|
-
return QWEN_API_CONFIG.baseUrl;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Get the chat completions endpoint
|
|
45
|
-
*/
|
|
46
|
-
private getChatEndpoint(): string {
|
|
47
|
-
return `${this.getBaseUrl()}/chat/completions`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Initialize the client with credentials
|
|
52
|
-
*/
|
|
53
|
-
async initialize(): Promise<boolean> {
|
|
54
|
-
this.credentials = await getValidCredentials();
|
|
55
|
-
return this.credentials !== null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Set credentials directly
|
|
60
|
-
*/
|
|
61
|
-
setCredentials(credentials: QwenCredentials): void {
|
|
62
|
-
this.credentials = credentials;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Get the authorization header
|
|
67
|
-
*/
|
|
68
|
-
private getAuthHeader(): string {
|
|
69
|
-
if (!this.credentials) {
|
|
70
|
-
throw new QwenAuthError('auth_required');
|
|
71
|
-
}
|
|
72
|
-
return `Bearer ${this.credentials.accessToken}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Log debug information
|
|
77
|
-
*/
|
|
78
|
-
private log(...args: unknown[]): void {
|
|
79
|
-
if (this.debug) {
|
|
80
|
-
console.log('[Qwen Client]', ...args);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Make a chat completion request
|
|
86
|
-
*/
|
|
87
|
-
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
|
|
88
|
-
if (!this.credentials) {
|
|
89
|
-
const initialized = await this.initialize();
|
|
90
|
-
if (!initialized) {
|
|
91
|
-
throw new QwenAuthError('auth_required');
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
this.log('Chat completion request:', JSON.stringify(request, null, 2));
|
|
96
|
-
|
|
97
|
-
const response = await fetch(this.getChatEndpoint(), {
|
|
98
|
-
method: 'POST',
|
|
99
|
-
headers: {
|
|
100
|
-
'Content-Type': 'application/json',
|
|
101
|
-
'Authorization': this.getAuthHeader(),
|
|
102
|
-
'Accept': 'application/json',
|
|
103
|
-
},
|
|
104
|
-
body: JSON.stringify(request),
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
if (!response.ok) {
|
|
108
|
-
const errorText = await response.text();
|
|
109
|
-
this.log('API Error:', response.status, errorText);
|
|
110
|
-
throw new QwenApiError(response.status, errorText);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const data = await response.json();
|
|
114
|
-
this.log('Chat completion response:', JSON.stringify(data, null, 2));
|
|
115
|
-
|
|
116
|
-
return data as ChatCompletionResponse;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Make a streaming chat completion request
|
|
121
|
-
*/
|
|
122
|
-
async *chatCompletionStream(request: ChatCompletionRequest): AsyncGenerator<StreamChunk> {
|
|
123
|
-
if (!this.credentials) {
|
|
124
|
-
const initialized = await this.initialize();
|
|
125
|
-
if (!initialized) {
|
|
126
|
-
throw new QwenAuthError('auth_required');
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const streamRequest = { ...request, stream: true };
|
|
131
|
-
this.log('Streaming chat completion request:', JSON.stringify(streamRequest, null, 2));
|
|
132
|
-
|
|
133
|
-
const response = await fetch(this.getChatEndpoint(), {
|
|
134
|
-
method: 'POST',
|
|
135
|
-
headers: {
|
|
136
|
-
'Content-Type': 'application/json',
|
|
137
|
-
'Authorization': this.getAuthHeader(),
|
|
138
|
-
'Accept': 'text/event-stream',
|
|
139
|
-
},
|
|
140
|
-
body: JSON.stringify(streamRequest),
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
if (!response.ok) {
|
|
144
|
-
const errorText = await response.text();
|
|
145
|
-
this.log('API Error:', response.status, errorText);
|
|
146
|
-
throw new QwenApiError(response.status, errorText);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const reader = response.body?.getReader();
|
|
150
|
-
if (!reader) {
|
|
151
|
-
throw new Error('No response body');
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const decoder = new TextDecoder();
|
|
155
|
-
let buffer = '';
|
|
156
|
-
|
|
157
|
-
while (true) {
|
|
158
|
-
const { done, value } = await reader.read();
|
|
159
|
-
if (done) break;
|
|
160
|
-
|
|
161
|
-
buffer += decoder.decode(value, { stream: true });
|
|
162
|
-
const lines = buffer.split('\n');
|
|
163
|
-
buffer = lines.pop() || '';
|
|
164
|
-
|
|
165
|
-
for (const line of lines) {
|
|
166
|
-
const trimmed = line.trim();
|
|
167
|
-
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
|
168
|
-
if (!trimmed.startsWith('data: ')) continue;
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
const json = trimmed.slice(6);
|
|
172
|
-
const chunk = JSON.parse(json) as StreamChunk;
|
|
173
|
-
this.log('Stream chunk:', JSON.stringify(chunk, null, 2));
|
|
174
|
-
yield chunk;
|
|
175
|
-
} catch (e) {
|
|
176
|
-
this.log('Failed to parse chunk:', trimmed, e);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* List available models
|
|
184
|
-
*/
|
|
185
|
-
async listModels(): Promise<Array<{ id: string; object: string; created: number }>> {
|
|
186
|
-
return Object.values(QWEN_MODELS).map(model => ({
|
|
187
|
-
id: model.id,
|
|
188
|
-
object: 'model',
|
|
189
|
-
created: Date.now(),
|
|
190
|
-
}));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Check if authenticated
|
|
195
|
-
*/
|
|
196
|
-
isAuthenticated(): boolean {
|
|
197
|
-
const creds = loadCredentials();
|
|
198
|
-
return creds !== null && !isCredentialsExpired(creds);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Get current credentials info
|
|
203
|
-
*/
|
|
204
|
-
getCredentialsInfo(): { authenticated: boolean; expiryDate?: number; resourceUrl?: string } {
|
|
205
|
-
const creds = loadCredentials();
|
|
206
|
-
if (!creds) {
|
|
207
|
-
return { authenticated: false };
|
|
208
|
-
}
|
|
209
|
-
return {
|
|
210
|
-
authenticated: !isCredentialsExpired(creds),
|
|
211
|
-
expiryDate: creds.expiryDate,
|
|
212
|
-
resourceUrl: creds.resourceUrl,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Export singleton instance
|
|
218
|
-
export const qwenClient = new QwenClient();
|
package/src/plugin/utils.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plugin Utilities
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Open URL in browser
|
|
7
|
-
*/
|
|
8
|
-
export async function openBrowser(url: string): Promise<void> {
|
|
9
|
-
try {
|
|
10
|
-
// Dynamic import for ESM compatibility
|
|
11
|
-
const open = await import('open');
|
|
12
|
-
await open.default(url);
|
|
13
|
-
} catch (error) {
|
|
14
|
-
// Fallback to console instruction
|
|
15
|
-
console.log(`\nPlease open this URL in your browser:\n${url}\n`);
|
|
16
|
-
}
|
|
17
|
-
}
|