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/src/index.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * OpenCode Qwen Auth Plugin
3
+ *
4
+ * Plugin de autenticação OAuth para Qwen, baseado no qwen-code.
5
+ * Implementa Device Flow (RFC 8628) para autenticação.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { spawn } from 'node:child_process';
10
+
11
+ import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS } from './constants.js';
12
+ import type { QwenCredentials } from './types.js';
13
+ import {
14
+ loadCredentials,
15
+ saveCredentials,
16
+ getCredentialsPath,
17
+ isCredentialsExpired,
18
+ } from './plugin/auth.js';
19
+ import {
20
+ generatePKCE,
21
+ requestDeviceAuthorization,
22
+ pollDeviceToken,
23
+ tokenResponseToCredentials,
24
+ refreshAccessToken,
25
+ } from './qwen/oauth.js';
26
+
27
+ // ============================================
28
+ // Helpers
29
+ // ============================================
30
+
31
+ function getBaseUrl(resourceUrl?: string): string {
32
+ if (!resourceUrl) return QWEN_API_CONFIG.baseUrl;
33
+ if (resourceUrl.startsWith('http')) {
34
+ return resourceUrl.endsWith('/v1') ? resourceUrl : `${resourceUrl}/v1`;
35
+ }
36
+ return `https://${resourceUrl}/v1`;
37
+ }
38
+
39
+ function openBrowser(url: string): void {
40
+ try {
41
+ const platform = process.platform;
42
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open';
43
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
44
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
45
+ child.unref?.();
46
+ } catch {
47
+ // Ignore errors
48
+ }
49
+ }
50
+
51
+ export function checkExistingCredentials(): QwenCredentials | null {
52
+ const credPath = getCredentialsPath();
53
+ if (existsSync(credPath)) {
54
+ const creds = loadCredentials();
55
+ if (creds && !isCredentialsExpired(creds)) {
56
+ return creds;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // ============================================
63
+ // Plugin Principal
64
+ // ============================================
65
+
66
+ export const QwenAuthPlugin = async (_input: unknown) => {
67
+ return {
68
+ auth: {
69
+ provider: QWEN_PROVIDER_ID,
70
+
71
+ loader: async (
72
+ getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>,
73
+ provider: { models?: Record<string, { cost?: { input: number; output: number } }> }
74
+ ) => {
75
+ const auth = await getAuth();
76
+
77
+ // Se não é OAuth, tentar carregar credenciais do qwen-code
78
+ if (!auth || auth.type !== 'oauth') {
79
+ const creds = checkExistingCredentials();
80
+ if (creds) {
81
+ return {
82
+ apiKey: creds.accessToken,
83
+ baseURL: getBaseUrl(creds.resourceUrl),
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ // Zerar custo dos modelos (gratuito via OAuth)
90
+ if (provider?.models) {
91
+ for (const model of Object.values(provider.models)) {
92
+ if (model) model.cost = { input: 0, output: 0 };
93
+ }
94
+ }
95
+
96
+ let accessToken = auth.access;
97
+
98
+ // Refresh se expirado
99
+ if (accessToken && auth.expires && Date.now() > auth.expires - 30000 && auth.refresh) {
100
+ try {
101
+ const refreshed = await refreshAccessToken(auth.refresh);
102
+ accessToken = refreshed.accessToken;
103
+ saveCredentials(refreshed);
104
+ } catch (e) {
105
+ console.error('[Qwen] Token refresh failed:', e);
106
+ }
107
+ }
108
+
109
+ // Fallback para credenciais do qwen-code
110
+ if (!accessToken) {
111
+ const creds = checkExistingCredentials();
112
+ if (creds) accessToken = creds.accessToken;
113
+ }
114
+
115
+ if (!accessToken) return null;
116
+
117
+ const creds = loadCredentials();
118
+ return {
119
+ apiKey: accessToken,
120
+ baseURL: getBaseUrl(creds?.resourceUrl),
121
+ };
122
+ },
123
+
124
+ methods: [
125
+ {
126
+ type: 'oauth',
127
+ label: 'Qwen Code (qwen.ai OAuth)',
128
+ authorize: async () => {
129
+ const { verifier, challenge } = generatePKCE();
130
+
131
+ try {
132
+ const deviceAuth = await requestDeviceAuthorization(challenge);
133
+ openBrowser(deviceAuth.verification_uri_complete);
134
+
135
+ const POLLING_MARGIN_MS = 3000;
136
+
137
+ return {
138
+ url: deviceAuth.verification_uri_complete,
139
+ instructions: `Código: ${deviceAuth.user_code}`,
140
+ method: 'auto' as const,
141
+ callback: async () => {
142
+ const startTime = Date.now();
143
+ const timeoutMs = deviceAuth.expires_in * 1000;
144
+ let interval = 5000;
145
+
146
+ while (Date.now() - startTime < timeoutMs) {
147
+ await Bun.sleep(interval + POLLING_MARGIN_MS);
148
+
149
+ try {
150
+ const tokenResponse = await pollDeviceToken(deviceAuth.device_code, verifier);
151
+
152
+ if (tokenResponse) {
153
+ const credentials = tokenResponseToCredentials(tokenResponse);
154
+ saveCredentials(credentials);
155
+
156
+ return {
157
+ type: 'success' as const,
158
+ access: credentials.accessToken,
159
+ refresh: credentials.refreshToken || '',
160
+ expires: credentials.expiryDate || Date.now() + 3600000,
161
+ };
162
+ }
163
+ } catch (e) {
164
+ const msg = e instanceof Error ? e.message : '';
165
+ if (msg.includes('slow_down')) {
166
+ interval = Math.min(interval + 5000, 15000);
167
+ } else if (!msg.includes('authorization_pending')) {
168
+ return { type: 'failed' as const };
169
+ }
170
+ }
171
+ }
172
+
173
+ return { type: 'failed' as const };
174
+ },
175
+ };
176
+ } catch (e) {
177
+ const msg = e instanceof Error ? e.message : 'Erro desconhecido';
178
+ return {
179
+ url: '',
180
+ instructions: `Erro: ${msg}`,
181
+ method: 'auto' as const,
182
+ callback: async () => ({ type: 'failed' as const }),
183
+ };
184
+ }
185
+ },
186
+ },
187
+ ],
188
+ },
189
+
190
+ config: async (config: Record<string, unknown>) => {
191
+ const providers = (config.provider as Record<string, unknown>) || {};
192
+
193
+ providers[QWEN_PROVIDER_ID] = {
194
+ npm: '@ai-sdk/openai-compatible',
195
+ name: 'Qwen Code',
196
+ options: { baseURL: QWEN_API_CONFIG.baseUrl },
197
+ models: Object.fromEntries(
198
+ Object.entries(QWEN_MODELS).map(([id, m]) => [
199
+ id,
200
+ {
201
+ id: m.id,
202
+ name: m.name,
203
+ reasoning: false,
204
+ limit: { context: m.contextWindow, output: m.maxOutput },
205
+ cost: m.cost,
206
+ modalities: { input: ['text'], output: ['text'] },
207
+ },
208
+ ])
209
+ ),
210
+ };
211
+
212
+ config.provider = providers;
213
+ },
214
+ };
215
+ };
216
+
217
+ export default QwenAuthPlugin;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Qwen Credentials Management
3
+ *
4
+ * Handles loading, saving, and validating credentials
5
+ */
6
+
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+
11
+ import type { QwenCredentials } from '../types.js';
12
+ import { refreshAccessToken, isCredentialsExpired } from '../qwen/oauth.js';
13
+
14
+ /**
15
+ * Get the path to the credentials file
16
+ * Uses the same location as qwen-code for compatibility
17
+ */
18
+ export function getCredentialsPath(): string {
19
+ const homeDir = homedir();
20
+ return join(homeDir, '.qwen', 'oauth_creds.json');
21
+ }
22
+
23
+ /**
24
+ * Get the OpenCode auth store path
25
+ */
26
+ export function getOpenCodeAuthPath(): string {
27
+ const homeDir = homedir();
28
+ return join(homeDir, '.local', 'share', 'opencode', 'auth.json');
29
+ }
30
+
31
+ /**
32
+ * Load existing Qwen credentials if available
33
+ * Supports qwen-code format with expiry_date and resource_url
34
+ */
35
+ export function loadCredentials(): QwenCredentials | null {
36
+ const credPath = getCredentialsPath();
37
+
38
+ if (!existsSync(credPath)) {
39
+ return null;
40
+ }
41
+
42
+ try {
43
+ const data = readFileSync(credPath, 'utf-8');
44
+ const parsed = JSON.parse(data);
45
+
46
+ // Handle qwen-code format and variations
47
+ if (parsed.access_token || parsed.accessToken) {
48
+ return {
49
+ accessToken: parsed.access_token || parsed.accessToken,
50
+ tokenType: parsed.token_type || parsed.tokenType || 'Bearer',
51
+ refreshToken: parsed.refresh_token || parsed.refreshToken,
52
+ resourceUrl: parsed.resource_url || parsed.resourceUrl,
53
+ // qwen-code uses expiry_date, fallback to expires_at for compatibility
54
+ expiryDate: parsed.expiry_date || parsed.expiresAt || parsed.expires_at,
55
+ scope: parsed.scope,
56
+ };
57
+ }
58
+
59
+ return null;
60
+ } catch (error) {
61
+ console.error('Error loading Qwen credentials:', error);
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Save credentials to file in qwen-code compatible format
68
+ */
69
+ export function saveCredentials(credentials: QwenCredentials): void {
70
+ const credPath = getCredentialsPath();
71
+ const dir = join(homedir(), '.qwen');
72
+
73
+ if (!existsSync(dir)) {
74
+ mkdirSync(dir, { recursive: true });
75
+ }
76
+
77
+ // Save in qwen-code format for compatibility
78
+ const data = {
79
+ access_token: credentials.accessToken,
80
+ token_type: credentials.tokenType || 'Bearer',
81
+ refresh_token: credentials.refreshToken,
82
+ resource_url: credentials.resourceUrl,
83
+ expiry_date: credentials.expiryDate,
84
+ scope: credentials.scope,
85
+ };
86
+
87
+ writeFileSync(credPath, JSON.stringify(data, null, 2));
88
+ }
89
+
90
+ /**
91
+ * Get valid credentials, refreshing if necessary
92
+ */
93
+ export async function getValidCredentials(): Promise<QwenCredentials | null> {
94
+ let credentials = loadCredentials();
95
+
96
+ if (!credentials) {
97
+ return null;
98
+ }
99
+
100
+ if (isCredentialsExpired(credentials) && credentials.refreshToken) {
101
+ try {
102
+ credentials = await refreshAccessToken(credentials.refreshToken);
103
+ saveCredentials(credentials);
104
+ } catch (error) {
105
+ console.error('Failed to refresh token:', error);
106
+ return null;
107
+ }
108
+ }
109
+
110
+ return credentials;
111
+ }
112
+
113
+ // Re-export isCredentialsExpired for convenience
114
+ export { isCredentialsExpired } from '../qwen/oauth.js';
@@ -0,0 +1,217 @@
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
+
16
+ /**
17
+ * QwenClient - Makes authenticated API calls to Qwen
18
+ */
19
+ export class QwenClient {
20
+ private credentials: QwenCredentials | null = null;
21
+ private debug: boolean;
22
+
23
+ constructor(options: { debug?: boolean } = {}) {
24
+ this.debug = options.debug || process.env.OPENCODE_QWEN_DEBUG === '1';
25
+ }
26
+
27
+ /**
28
+ * Get the API base URL from credentials or fallback to default
29
+ */
30
+ private getBaseUrl(): string {
31
+ if (this.credentials?.resourceUrl) {
32
+ // resourceUrl from qwen-code is just the host, need to add protocol and path
33
+ const resourceUrl = this.credentials.resourceUrl;
34
+ if (resourceUrl.startsWith('http')) {
35
+ return resourceUrl.endsWith('/v1') ? resourceUrl : `${resourceUrl}/v1`;
36
+ }
37
+ return `https://${resourceUrl}/v1`;
38
+ }
39
+ return QWEN_API_CONFIG.baseUrl;
40
+ }
41
+
42
+ /**
43
+ * Get the chat completions endpoint
44
+ */
45
+ private getChatEndpoint(): string {
46
+ return `${this.getBaseUrl()}/chat/completions`;
47
+ }
48
+
49
+ /**
50
+ * Initialize the client with credentials
51
+ */
52
+ async initialize(): Promise<boolean> {
53
+ this.credentials = await getValidCredentials();
54
+ return this.credentials !== null;
55
+ }
56
+
57
+ /**
58
+ * Set credentials directly
59
+ */
60
+ setCredentials(credentials: QwenCredentials): void {
61
+ this.credentials = credentials;
62
+ }
63
+
64
+ /**
65
+ * Get the authorization header
66
+ */
67
+ private getAuthHeader(): string {
68
+ if (!this.credentials) {
69
+ throw new Error('Not authenticated. Please run the OAuth flow first.');
70
+ }
71
+ return `Bearer ${this.credentials.accessToken}`;
72
+ }
73
+
74
+ /**
75
+ * Log debug information
76
+ */
77
+ private log(...args: unknown[]): void {
78
+ if (this.debug) {
79
+ console.log('[Qwen Client]', ...args);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Make a chat completion request
85
+ */
86
+ async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
87
+ if (!this.credentials) {
88
+ const initialized = await this.initialize();
89
+ if (!initialized) {
90
+ throw new Error('No valid Qwen credentials found. Please authenticate first.');
91
+ }
92
+ }
93
+
94
+ this.log('Chat completion request:', JSON.stringify(request, null, 2));
95
+
96
+ const response = await fetch(this.getChatEndpoint(), {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ 'Authorization': this.getAuthHeader(),
101
+ 'Accept': 'application/json',
102
+ },
103
+ body: JSON.stringify(request),
104
+ });
105
+
106
+ if (!response.ok) {
107
+ const errorText = await response.text();
108
+ this.log('API Error:', response.status, errorText);
109
+ throw new Error(`Qwen API error: ${response.status} - ${errorText}`);
110
+ }
111
+
112
+ const data = await response.json();
113
+ this.log('Chat completion response:', JSON.stringify(data, null, 2));
114
+
115
+ return data as ChatCompletionResponse;
116
+ }
117
+
118
+ /**
119
+ * Make a streaming chat completion request
120
+ */
121
+ async *chatCompletionStream(request: ChatCompletionRequest): AsyncGenerator<StreamChunk> {
122
+ if (!this.credentials) {
123
+ const initialized = await this.initialize();
124
+ if (!initialized) {
125
+ throw new Error('No valid Qwen credentials found. Please authenticate first.');
126
+ }
127
+ }
128
+
129
+ const streamRequest = { ...request, stream: true };
130
+ this.log('Streaming chat completion request:', JSON.stringify(streamRequest, null, 2));
131
+
132
+ const response = await fetch(this.getChatEndpoint(), {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'Authorization': this.getAuthHeader(),
137
+ 'Accept': 'text/event-stream',
138
+ },
139
+ body: JSON.stringify(streamRequest),
140
+ });
141
+
142
+ if (!response.ok) {
143
+ const errorText = await response.text();
144
+ this.log('API Error:', response.status, errorText);
145
+ throw new Error(`Qwen API error: ${response.status} - ${errorText}`);
146
+ }
147
+
148
+ const reader = response.body?.getReader();
149
+ if (!reader) {
150
+ throw new Error('No response body');
151
+ }
152
+
153
+ const decoder = new TextDecoder();
154
+ let buffer = '';
155
+
156
+ while (true) {
157
+ const { done, value } = await reader.read();
158
+ if (done) break;
159
+
160
+ buffer += decoder.decode(value, { stream: true });
161
+ const lines = buffer.split('\n');
162
+ buffer = lines.pop() || '';
163
+
164
+ for (const line of lines) {
165
+ const trimmed = line.trim();
166
+ if (!trimmed || trimmed === 'data: [DONE]') continue;
167
+ if (!trimmed.startsWith('data: ')) continue;
168
+
169
+ try {
170
+ const json = trimmed.slice(6);
171
+ const chunk = JSON.parse(json) as StreamChunk;
172
+ this.log('Stream chunk:', JSON.stringify(chunk, null, 2));
173
+ yield chunk;
174
+ } catch (e) {
175
+ this.log('Failed to parse chunk:', trimmed, e);
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * List available models
183
+ */
184
+ async listModels(): Promise<Array<{ id: string; object: string; created: number }>> {
185
+ return Object.values(QWEN_MODELS).map(model => ({
186
+ id: model.id,
187
+ object: 'model',
188
+ created: Date.now(),
189
+ }));
190
+ }
191
+
192
+ /**
193
+ * Check if authenticated
194
+ */
195
+ isAuthenticated(): boolean {
196
+ const creds = loadCredentials();
197
+ return creds !== null && !isCredentialsExpired(creds);
198
+ }
199
+
200
+ /**
201
+ * Get current credentials info
202
+ */
203
+ getCredentialsInfo(): { authenticated: boolean; expiryDate?: number; resourceUrl?: string } {
204
+ const creds = loadCredentials();
205
+ if (!creds) {
206
+ return { authenticated: false };
207
+ }
208
+ return {
209
+ authenticated: !isCredentialsExpired(creds),
210
+ expiryDate: creds.expiryDate,
211
+ resourceUrl: creds.resourceUrl,
212
+ };
213
+ }
214
+ }
215
+
216
+ // Export singleton instance
217
+ export const qwenClient = new QwenClient();
@@ -0,0 +1,17 @@
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
+ }