@hung319/opencode-qwen 1.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/README.md +180 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.js +63 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/plugin/accounts.d.ts +27 -0
- package/dist/plugin/accounts.js +149 -0
- package/dist/plugin/cli.d.ts +9 -0
- package/dist/plugin/cli.js +37 -0
- package/dist/plugin/config/index.d.ts +32 -0
- package/dist/plugin/config/index.js +47 -0
- package/dist/plugin/errors.d.ts +11 -0
- package/dist/plugin/errors.js +22 -0
- package/dist/plugin/logger.d.ts +23 -0
- package/dist/plugin/logger.js +40 -0
- package/dist/plugin/storage.d.ts +11 -0
- package/dist/plugin/storage.js +58 -0
- package/dist/plugin/token.d.ts +2 -0
- package/dist/plugin/token.js +32 -0
- package/dist/plugin/types.d.ts +30 -0
- package/dist/plugin/types.js +0 -0
- package/dist/plugin.d.ts +37 -0
- package/dist/plugin.js +476 -0
- package/dist/qwen/models.d.ts +64 -0
- package/dist/qwen/models.js +113 -0
- package/dist/qwen/token.d.ts +13 -0
- package/dist/qwen/token.js +85 -0
- package/package.json +63 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ManagedAccount } from './types';
|
|
2
|
+
export interface StorageData {
|
|
3
|
+
accounts: ManagedAccount[];
|
|
4
|
+
createdAt: number;
|
|
5
|
+
updatedAt: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class Storage {
|
|
8
|
+
static read(): Promise<StorageData | null>;
|
|
9
|
+
static write(data: StorageData): Promise<void>;
|
|
10
|
+
static exists(): boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import * as fsSync from 'fs';
|
|
5
|
+
import * as lockfile from 'proper-lockfile';
|
|
6
|
+
const ACCOUNTS_FILE = join(homedir(), '.config', 'opencode', 'qwen-accounts.json');
|
|
7
|
+
export class Storage {
|
|
8
|
+
static async read() {
|
|
9
|
+
try {
|
|
10
|
+
// Check if file exists
|
|
11
|
+
await fs.access(ACCOUNTS_FILE);
|
|
12
|
+
// Try to acquire lock
|
|
13
|
+
const release = await lockfile.lock(ACCOUNTS_FILE, {
|
|
14
|
+
retries: 3,
|
|
15
|
+
stale: 60000, // 1 minute
|
|
16
|
+
onCompromised: (err) => console.error('Lock compromised:', err)
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
const content = await fs.readFile(ACCOUNTS_FILE, 'utf8');
|
|
20
|
+
const data = JSON.parse(content);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await release();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code === 'ENOENT') {
|
|
29
|
+
// File doesn't exist, return null
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
static async write(data) {
|
|
36
|
+
// Ensure directory exists
|
|
37
|
+
const dir = join(homedir(), '.config', 'opencode');
|
|
38
|
+
await fs.mkdir(dir, { recursive: true });
|
|
39
|
+
// Try to acquire lock
|
|
40
|
+
const release = await lockfile.lock(ACCOUNTS_FILE, {
|
|
41
|
+
retries: 3,
|
|
42
|
+
stale: 60000, // 1 minute
|
|
43
|
+
onCompromised: (err) => console.error('Lock compromised:', err)
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
// Update timestamp
|
|
47
|
+
data.updatedAt = Date.now();
|
|
48
|
+
// Write the file
|
|
49
|
+
await fs.writeFile(ACCOUNTS_FILE, JSON.stringify(data, null, 2));
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await release();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
static exists() {
|
|
56
|
+
return fsSync.existsSync(ACCOUNTS_FILE);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function accessTokenExpired(expiresAt) {
|
|
2
|
+
if (!expiresAt)
|
|
3
|
+
return false;
|
|
4
|
+
return Date.now() >= expiresAt;
|
|
5
|
+
}
|
|
6
|
+
export async function refreshAccessToken(authDetails) {
|
|
7
|
+
// In the Qwen API, token refresh is handled differently
|
|
8
|
+
// We'll use the refresh endpoint if available
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch('https://qwen.aikit.club/refresh', {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: {
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
},
|
|
15
|
+
body: JSON.stringify({ token: authDetails.apiKey }),
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
19
|
+
}
|
|
20
|
+
const data = await response.json();
|
|
21
|
+
// Return updated auth details
|
|
22
|
+
return {
|
|
23
|
+
...authDetails,
|
|
24
|
+
apiKey: data.access_token,
|
|
25
|
+
expiresAt: data.expires_at ? data.expires_at * 1000 : undefined // Convert to milliseconds
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.error('Token refresh error:', error.message);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type QwenAuthMethod = 'token';
|
|
2
|
+
export interface QwenAuthDetails {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
email?: string;
|
|
5
|
+
expiresAt?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ManagedAccount {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string;
|
|
10
|
+
authMethod: 'token';
|
|
11
|
+
apiKey: string;
|
|
12
|
+
refreshToken?: string;
|
|
13
|
+
accessToken?: string;
|
|
14
|
+
expiresAt?: number;
|
|
15
|
+
rateLimitResetTime: number;
|
|
16
|
+
isHealthy: boolean;
|
|
17
|
+
lastUsed?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface TokenRefreshResult {
|
|
20
|
+
type: 'success';
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken?: string;
|
|
23
|
+
expiresAt?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface TokenValidationResult {
|
|
26
|
+
valid: boolean;
|
|
27
|
+
email?: string;
|
|
28
|
+
expiresAt?: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
File without changes
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const QWEN_PROVIDER_ID = "qwen";
|
|
2
|
+
export declare const createQwenPlugin: (id: string) => ({ client, directory }: any) => Promise<{
|
|
3
|
+
config: (config: any) => Promise<void>;
|
|
4
|
+
auth: {
|
|
5
|
+
provider: string;
|
|
6
|
+
loader: (getAuth: any, provider: any) => Promise<{
|
|
7
|
+
apiKey: string;
|
|
8
|
+
baseURL: string;
|
|
9
|
+
models: any;
|
|
10
|
+
fetch(input: any, init?: any): Promise<Response>;
|
|
11
|
+
}>;
|
|
12
|
+
methods: {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
type: "api";
|
|
16
|
+
authorize: (inputs?: any) => Promise<unknown>;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
}>;
|
|
20
|
+
export declare const QwenOAuthPlugin: ({ client, directory }: any) => Promise<{
|
|
21
|
+
config: (config: any) => Promise<void>;
|
|
22
|
+
auth: {
|
|
23
|
+
provider: string;
|
|
24
|
+
loader: (getAuth: any, provider: any) => Promise<{
|
|
25
|
+
apiKey: string;
|
|
26
|
+
baseURL: string;
|
|
27
|
+
models: any;
|
|
28
|
+
fetch(input: any, init?: any): Promise<Response>;
|
|
29
|
+
}>;
|
|
30
|
+
methods: {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
type: "api";
|
|
34
|
+
authorize: (inputs?: any) => Promise<unknown>;
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
37
|
+
}>;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { loadConfig } from './plugin/config';
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
3
|
+
import { AccountManager, generateAccountId } from './plugin/accounts';
|
|
4
|
+
import { accessTokenExpired } from './plugin/token';
|
|
5
|
+
import { refreshAccessToken } from './plugin/token';
|
|
6
|
+
import { validateToken, refreshToken } from './qwen/token';
|
|
7
|
+
import { getModels } from './qwen/models';
|
|
8
|
+
import { promptAddAnotherAccount, promptLoginMode, promptToken, promptEmail, } from './plugin/cli';
|
|
9
|
+
import { QWEN_CONSTANTS, applyThinkingConfig } from './constants';
|
|
10
|
+
import * as logger from './plugin/logger';
|
|
11
|
+
export const QWEN_PROVIDER_ID = 'qwen';
|
|
12
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
const isNetworkError = (e) => e instanceof Error && /econnreset|etimedout|enotfound|network|fetch failed/i.test(e.message);
|
|
14
|
+
const openBrowser = (url) => {
|
|
15
|
+
const escapedUrl = url.replace(/"/g, '\\"');
|
|
16
|
+
const platform = process.platform;
|
|
17
|
+
const command = platform === 'win32'
|
|
18
|
+
? `cmd /c start "" "${escapedUrl}"`
|
|
19
|
+
: platform === 'darwin'
|
|
20
|
+
? `open "${escapedUrl}"`
|
|
21
|
+
: `xdg-open "${escapedUrl}"`;
|
|
22
|
+
exec(command, (error) => {
|
|
23
|
+
if (error)
|
|
24
|
+
logger.warn(`Failed to open browser automatically: ${error.message}`, error);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Default model configurations for Qwen API
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_MODELS = {
|
|
31
|
+
'qwen-max-latest': {
|
|
32
|
+
name: 'Qwen Max Latest',
|
|
33
|
+
limit: { context: 1000000, output: 8000 },
|
|
34
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
35
|
+
},
|
|
36
|
+
'qwen3-coder-plus': {
|
|
37
|
+
name: 'Qwen3 Coder Plus',
|
|
38
|
+
limit: { context: 128000, output: 8000 },
|
|
39
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
40
|
+
supports_tools: true
|
|
41
|
+
},
|
|
42
|
+
'qwen-deep-research': {
|
|
43
|
+
name: 'Qwen Deep Research',
|
|
44
|
+
limit: { context: 1000000, output: 8000 },
|
|
45
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
46
|
+
},
|
|
47
|
+
'qwen2.5-max': {
|
|
48
|
+
name: 'Qwen2.5 Max',
|
|
49
|
+
limit: { context: 32000, output: 8000 },
|
|
50
|
+
modalities: { input: ['text', 'image'], output: ['text'] }
|
|
51
|
+
},
|
|
52
|
+
'qwen2.5-plus': {
|
|
53
|
+
name: 'Qwen2.5 Plus',
|
|
54
|
+
limit: { context: 128000, output: 8000 },
|
|
55
|
+
modalities: { input: ['text', 'image'], output: ['text'] }
|
|
56
|
+
},
|
|
57
|
+
'qwen2.5-turbo': {
|
|
58
|
+
name: 'Qwen2.5 Turbo',
|
|
59
|
+
limit: { context: 32000, output: 8000 },
|
|
60
|
+
modalities: { input: ['text', 'image'], output: ['text'] }
|
|
61
|
+
},
|
|
62
|
+
'qwen2.5-vl-32b-instruct': {
|
|
63
|
+
name: 'Qwen2.5 VL 32B Instruct',
|
|
64
|
+
limit: { context: 128000, output: 8000 },
|
|
65
|
+
modalities: { input: ['text', 'image'], output: ['text'] }
|
|
66
|
+
},
|
|
67
|
+
'qwen-web-dev': {
|
|
68
|
+
name: 'Qwen Web Dev',
|
|
69
|
+
limit: { context: 128000, output: 8000 },
|
|
70
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
71
|
+
},
|
|
72
|
+
'qwen-full-stack': {
|
|
73
|
+
name: 'Qwen Full Stack',
|
|
74
|
+
limit: { context: 128000, output: 8000 },
|
|
75
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
76
|
+
},
|
|
77
|
+
'qwen3-max': {
|
|
78
|
+
name: 'Qwen3 Max',
|
|
79
|
+
limit: { context: 1000000, output: 8000 },
|
|
80
|
+
modalities: { input: ['text', 'image'], output: ['text'] }
|
|
81
|
+
},
|
|
82
|
+
'qvq-max': {
|
|
83
|
+
name: 'QVQ Max',
|
|
84
|
+
limit: { context: 128000, output: 8000 },
|
|
85
|
+
modalities: { input: ['text', 'image'], output: ['text'] }
|
|
86
|
+
},
|
|
87
|
+
'qwq-32b': {
|
|
88
|
+
name: 'QWQ 32B',
|
|
89
|
+
limit: { context: 128000, output: 8000 },
|
|
90
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
export const createQwenPlugin = (id) => async ({ client, directory }) => {
|
|
94
|
+
const config = loadConfig();
|
|
95
|
+
const showToast = (message, variant) => {
|
|
96
|
+
client.tui.showToast({ body: { message, variant } }).catch(() => { });
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
config: async (config) => {
|
|
100
|
+
// Register qwen provider with models
|
|
101
|
+
config.provider = config.provider || {};
|
|
102
|
+
config.provider[id] = config.provider[id] || {};
|
|
103
|
+
// Try to fetch models from API
|
|
104
|
+
let fetchedModels = {};
|
|
105
|
+
try {
|
|
106
|
+
const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
107
|
+
const accounts = am.getAccounts();
|
|
108
|
+
if (accounts.length > 0) {
|
|
109
|
+
// Use first available account to fetch models
|
|
110
|
+
const firstAccount = accounts[0];
|
|
111
|
+
if (firstAccount) {
|
|
112
|
+
const token = firstAccount.apiKey;
|
|
113
|
+
if (token) {
|
|
114
|
+
logger.log('Fetching models from Qwen API...');
|
|
115
|
+
fetchedModels = await getModels(token);
|
|
116
|
+
logger.log(`Fetched ${Object.keys(fetchedModels).length} models from API`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
logger.warn(`Failed to fetch models from API: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
// Merge: fetched models > default models > existing config
|
|
125
|
+
config.provider[id].models = {
|
|
126
|
+
...DEFAULT_MODELS,
|
|
127
|
+
...fetchedModels,
|
|
128
|
+
...(config.provider[id].models || {})
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
auth: {
|
|
132
|
+
provider: id,
|
|
133
|
+
loader: async (getAuth, provider) => {
|
|
134
|
+
await getAuth();
|
|
135
|
+
const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
136
|
+
// Auto-refresh tokens for all accounts on startup
|
|
137
|
+
const accounts = am.getAccounts();
|
|
138
|
+
for (const account of accounts) {
|
|
139
|
+
if (account.apiKey) {
|
|
140
|
+
try {
|
|
141
|
+
logger.log(`Auto-refreshing token for account: ${account.email}`);
|
|
142
|
+
const refreshResult = await refreshToken(account.apiKey);
|
|
143
|
+
// Update the account with the new token
|
|
144
|
+
account.apiKey = refreshResult.apiKey;
|
|
145
|
+
if (refreshResult.expiresAt) {
|
|
146
|
+
account.expiresAt = refreshResult.expiresAt;
|
|
147
|
+
}
|
|
148
|
+
logger.log(`Successfully refreshed token for account: ${account.email}`);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
logger.warn(`Failed to refresh token for account ${account.email}: ${error.message}`);
|
|
152
|
+
// Continue with the original token if refresh fails
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Save updated accounts with refreshed tokens
|
|
157
|
+
await am.saveToDisk();
|
|
158
|
+
// Auto-configure models if not already configured
|
|
159
|
+
const configuredModels = provider?.models || {};
|
|
160
|
+
const mergedModels = { ...DEFAULT_MODELS, ...configuredModels };
|
|
161
|
+
return {
|
|
162
|
+
apiKey: '',
|
|
163
|
+
baseURL: config.base_url,
|
|
164
|
+
models: mergedModels,
|
|
165
|
+
async fetch(input, init) {
|
|
166
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
167
|
+
let retry = 0;
|
|
168
|
+
let iterations = 0;
|
|
169
|
+
const startTime = Date.now();
|
|
170
|
+
const maxIterations = config.max_request_iterations;
|
|
171
|
+
const timeoutMs = config.request_timeout_ms;
|
|
172
|
+
while (true) {
|
|
173
|
+
iterations++;
|
|
174
|
+
const elapsed = Date.now() - startTime;
|
|
175
|
+
if (iterations > maxIterations) {
|
|
176
|
+
throw new Error(`Request exceeded max iterations (${maxIterations}). All accounts may be unhealthy or rate-limited.`);
|
|
177
|
+
}
|
|
178
|
+
if (elapsed > timeoutMs) {
|
|
179
|
+
throw new Error(`Request timeout after ${Math.ceil(elapsed / 1000)}s. Max timeout: ${Math.ceil(timeoutMs / 1000)}s.`);
|
|
180
|
+
}
|
|
181
|
+
const count = am.getAccountCount();
|
|
182
|
+
if (count === 0)
|
|
183
|
+
throw new Error('No accounts. Login first.');
|
|
184
|
+
const acc = am.getCurrentOrNext();
|
|
185
|
+
if (!acc) {
|
|
186
|
+
const minWait = am.getMinWaitTime();
|
|
187
|
+
if (minWait > 0) {
|
|
188
|
+
showToast(`All accounts rate-limited. Waiting ${Math.ceil(minWait / 1000)}s...`, 'warning');
|
|
189
|
+
await sleep(Math.min(minWait, 5000));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
throw new Error('No healthy accounts available');
|
|
193
|
+
}
|
|
194
|
+
if (count > 1 && am.shouldShowToast()) {
|
|
195
|
+
showToast(`Using ${acc.email} (${am.getAccounts().indexOf(acc) + 1}/${count})`, 'info');
|
|
196
|
+
}
|
|
197
|
+
// Note: Qwen tokens are typically long-lived, but we'll still check
|
|
198
|
+
// In practice, many Qwen tokens don't have expiration times
|
|
199
|
+
if (acc.expiresAt && accessTokenExpired(acc.expiresAt)) {
|
|
200
|
+
try {
|
|
201
|
+
const authDetails = am.toAuthDetails(acc);
|
|
202
|
+
const refreshed = await refreshAccessToken(authDetails);
|
|
203
|
+
am.updateFromAuth(acc, refreshed);
|
|
204
|
+
await am.saveToDisk();
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
logger.error(`Token refresh failed for account ${acc.id}`, error);
|
|
208
|
+
am.markUnhealthy(acc, 'Token refresh failed', Date.now() + 300000);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const body = init?.body ? JSON.parse(init.body) : {};
|
|
213
|
+
const model = body.model || 'qwen-max-latest';
|
|
214
|
+
let processedBody = applyThinkingConfig(body, model);
|
|
215
|
+
if (processedBody.stream === false && processedBody.stream_options) {
|
|
216
|
+
const { stream_options, ...rest } = processedBody;
|
|
217
|
+
processedBody = rest;
|
|
218
|
+
}
|
|
219
|
+
const apiTimestamp = config.enable_log_api_request ? logger.getTimestamp() : null;
|
|
220
|
+
const incomingHeaders = init?.headers || {};
|
|
221
|
+
const cleanedHeaders = {};
|
|
222
|
+
for (const [key, value] of Object.entries(incomingHeaders)) {
|
|
223
|
+
const lowerKey = key.toLowerCase();
|
|
224
|
+
if (lowerKey !== 'authorization' &&
|
|
225
|
+
lowerKey !== 'user-agent' &&
|
|
226
|
+
lowerKey !== 'content-type') {
|
|
227
|
+
cleanedHeaders[key] = value;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const headers = {
|
|
231
|
+
Authorization: `Bearer ${acc.apiKey}`,
|
|
232
|
+
'User-Agent': QWEN_CONSTANTS.USER_AGENT,
|
|
233
|
+
'Content-Type': 'application/json',
|
|
234
|
+
...cleanedHeaders
|
|
235
|
+
};
|
|
236
|
+
if (apiTimestamp) {
|
|
237
|
+
const sanitizedHeaders = {
|
|
238
|
+
...headers,
|
|
239
|
+
Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
|
|
240
|
+
};
|
|
241
|
+
const requestData = {
|
|
242
|
+
url: typeof input === 'string' ? input : input.url,
|
|
243
|
+
method: init?.method || 'POST',
|
|
244
|
+
headers: sanitizedHeaders,
|
|
245
|
+
body: processedBody,
|
|
246
|
+
account: acc.email
|
|
247
|
+
};
|
|
248
|
+
logger.logApiRequest(requestData, apiTimestamp);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const response = await fetch(input, {
|
|
252
|
+
...init,
|
|
253
|
+
headers,
|
|
254
|
+
body: JSON.stringify(processedBody),
|
|
255
|
+
method: init?.method || 'POST'
|
|
256
|
+
});
|
|
257
|
+
if (response.ok) {
|
|
258
|
+
if (apiTimestamp) {
|
|
259
|
+
const responseData = {
|
|
260
|
+
status: response.status,
|
|
261
|
+
statusText: response.statusText,
|
|
262
|
+
headers: {},
|
|
263
|
+
account: acc.email
|
|
264
|
+
};
|
|
265
|
+
logger.logApiResponse(responseData, apiTimestamp);
|
|
266
|
+
}
|
|
267
|
+
return response;
|
|
268
|
+
}
|
|
269
|
+
const errorText = await response.text().catch(() => '');
|
|
270
|
+
const sanitizedHeaders = {
|
|
271
|
+
...headers,
|
|
272
|
+
Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
|
|
273
|
+
};
|
|
274
|
+
const requestData = {
|
|
275
|
+
url: typeof input === 'string' ? input : input.url,
|
|
276
|
+
method: init?.method || 'POST',
|
|
277
|
+
headers: sanitizedHeaders,
|
|
278
|
+
body: processedBody,
|
|
279
|
+
account: acc.email
|
|
280
|
+
};
|
|
281
|
+
const responseData = {
|
|
282
|
+
status: response.status,
|
|
283
|
+
statusText: response.statusText,
|
|
284
|
+
headers: {},
|
|
285
|
+
body: errorText,
|
|
286
|
+
account: acc.email
|
|
287
|
+
};
|
|
288
|
+
if (config.enable_log_api_request && apiTimestamp) {
|
|
289
|
+
logger.logApiResponse(responseData, apiTimestamp);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const errorTimestamp = logger.getTimestamp();
|
|
293
|
+
logger.logApiError(requestData, responseData, errorTimestamp);
|
|
294
|
+
}
|
|
295
|
+
if (response.status === 429) {
|
|
296
|
+
const retryAfter = parseInt(response.headers.get('retry-after') || '60', 10);
|
|
297
|
+
logger.warn(`Rate limited on account ${acc.email}, retry after ${retryAfter}s`);
|
|
298
|
+
am.markRateLimited(acc, retryAfter * 1000);
|
|
299
|
+
await sleep(1000);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (response.status === 401 || response.status === 403) {
|
|
303
|
+
logger.warn(`Authentication failed for ${acc.email}: ${response.status}`);
|
|
304
|
+
am.markUnhealthy(acc, 'Authentication failed', Date.now() + 300000);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (response.status >= 500) {
|
|
308
|
+
if (retry < 3) {
|
|
309
|
+
retry++;
|
|
310
|
+
logger.warn(`Server error ${response.status}, retry ${retry}/3`);
|
|
311
|
+
await sleep(1000 * Math.pow(2, retry));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
logger.error(`Server error ${response.status} after ${retry} retries`);
|
|
315
|
+
am.markUnhealthy(acc, 'Server error', Date.now() + 300000);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`Qwen API Error: ${response.status} - ${errorText}`);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (isNetworkError(error) && retry < 3) {
|
|
322
|
+
retry++;
|
|
323
|
+
logger.warn(`Network error, retry ${retry}/3: ${error.message}`);
|
|
324
|
+
await sleep(1000 * Math.pow(2, retry));
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const sanitizedHeaders = {
|
|
328
|
+
...headers,
|
|
329
|
+
Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
|
|
330
|
+
};
|
|
331
|
+
const requestData = {
|
|
332
|
+
url: typeof input === 'string' ? input : input.url,
|
|
333
|
+
method: init?.method || 'POST',
|
|
334
|
+
headers: sanitizedHeaders,
|
|
335
|
+
body: processedBody,
|
|
336
|
+
account: acc.email
|
|
337
|
+
};
|
|
338
|
+
const networkErrorData = {
|
|
339
|
+
status: 0,
|
|
340
|
+
statusText: 'Network Error',
|
|
341
|
+
headers: {},
|
|
342
|
+
body: error.message,
|
|
343
|
+
account: acc.email
|
|
344
|
+
};
|
|
345
|
+
if (config.enable_log_api_request && apiTimestamp) {
|
|
346
|
+
logger.logApiResponse(networkErrorData, apiTimestamp);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const errorTimestamp = logger.getTimestamp();
|
|
350
|
+
logger.logApiError(requestData, networkErrorData, errorTimestamp);
|
|
351
|
+
}
|
|
352
|
+
logger.error(`Request failed after ${retry} retries: ${error.message}`, error);
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
methods: [
|
|
360
|
+
{
|
|
361
|
+
id: 'token',
|
|
362
|
+
label: 'Qwen API Token',
|
|
363
|
+
type: 'api',
|
|
364
|
+
authorize: async (inputs) => new Promise(async (resolve) => {
|
|
365
|
+
if (inputs) {
|
|
366
|
+
const accounts = [];
|
|
367
|
+
let startFresh = true;
|
|
368
|
+
const existingAm = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
369
|
+
if (existingAm.getAccountCount() > 0) {
|
|
370
|
+
const existingAccounts = existingAm.getAccounts().map((acc, idx) => ({
|
|
371
|
+
email: acc.email,
|
|
372
|
+
index: idx
|
|
373
|
+
}));
|
|
374
|
+
const loginMode = await promptLoginMode(existingAccounts);
|
|
375
|
+
startFresh = loginMode === 'fresh';
|
|
376
|
+
console.log(startFresh
|
|
377
|
+
? '\nStarting fresh - existing accounts will be replaced.\n'
|
|
378
|
+
: '\nAdding to existing accounts.\n');
|
|
379
|
+
}
|
|
380
|
+
while (true) {
|
|
381
|
+
console.log(`\n=== Qwen API Token (Account ${accounts.length + 1}) ===\n`);
|
|
382
|
+
const token = await promptToken();
|
|
383
|
+
if (!token) {
|
|
384
|
+
if (accounts.length === 0) {
|
|
385
|
+
return resolve({
|
|
386
|
+
url: '',
|
|
387
|
+
instructions: 'API token required',
|
|
388
|
+
method: 'auto',
|
|
389
|
+
callback: async () => ({ type: 'failed' })
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const validation = await validateToken(token);
|
|
396
|
+
const email = await promptEmail();
|
|
397
|
+
accounts.push({ apiKey: token, email });
|
|
398
|
+
const isFirstAccount = accounts.length === 1;
|
|
399
|
+
const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
400
|
+
if (isFirstAccount && startFresh) {
|
|
401
|
+
am.getAccounts().forEach((acc) => am.removeAccount(acc));
|
|
402
|
+
}
|
|
403
|
+
const acc = {
|
|
404
|
+
id: generateAccountId(),
|
|
405
|
+
email,
|
|
406
|
+
authMethod: 'token',
|
|
407
|
+
apiKey: token,
|
|
408
|
+
rateLimitResetTime: 0,
|
|
409
|
+
isHealthy: true
|
|
410
|
+
};
|
|
411
|
+
am.addAccount(acc);
|
|
412
|
+
await am.saveToDisk();
|
|
413
|
+
showToast(`Account ${accounts.length} added (${email})`, 'success');
|
|
414
|
+
let currentAccountCount = accounts.length;
|
|
415
|
+
try {
|
|
416
|
+
const currentStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
417
|
+
currentAccountCount = currentStorage.getAccountCount();
|
|
418
|
+
}
|
|
419
|
+
catch (e) {
|
|
420
|
+
logger.warn(`Failed to load account count: ${e.message}`);
|
|
421
|
+
}
|
|
422
|
+
const addAnother = await promptAddAnotherAccount(currentAccountCount);
|
|
423
|
+
if (!addAnother) {
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
console.error(`API token validation failed: ${error.message}`);
|
|
429
|
+
if (accounts.length === 0) {
|
|
430
|
+
return resolve({
|
|
431
|
+
url: '',
|
|
432
|
+
instructions: `API token validation failed: ${error.message}`,
|
|
433
|
+
method: 'auto',
|
|
434
|
+
callback: async () => ({ type: 'failed' })
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const primary = accounts[0];
|
|
441
|
+
if (!primary) {
|
|
442
|
+
return resolve({
|
|
443
|
+
url: '',
|
|
444
|
+
instructions: 'Authentication cancelled',
|
|
445
|
+
method: 'auto',
|
|
446
|
+
callback: async () => ({ type: 'failed' })
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
let actualAccountCount = accounts.length;
|
|
450
|
+
try {
|
|
451
|
+
const finalStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
452
|
+
actualAccountCount = finalStorage.getAccountCount();
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
logger.warn(`Failed to load account count: ${e.message}`);
|
|
456
|
+
}
|
|
457
|
+
return resolve({
|
|
458
|
+
url: '',
|
|
459
|
+
instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
|
|
460
|
+
method: 'auto',
|
|
461
|
+
callback: async () => ({ type: 'success', key: primary.apiKey })
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
resolve({
|
|
465
|
+
url: '',
|
|
466
|
+
instructions: 'Token authentication not supported in TUI mode. Use CLI: opencode auth login',
|
|
467
|
+
method: 'auto',
|
|
468
|
+
callback: async () => ({ type: 'failed' })
|
|
469
|
+
});
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
]
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
export const QwenOAuthPlugin = createQwenPlugin(QWEN_PROVIDER_ID);
|