350 lines
10 KiB
JavaScript
350 lines
10 KiB
JavaScript
/**
|
|
* Qwen OAuth / CLI Adapter for OpenQode
|
|
*
|
|
* Primary goal: make Gen5 TUI + Goose use the SAME auth as the Qwen CLI (option [4]).
|
|
*
|
|
* Strategy:
|
|
* - Text chat: always call `qwen` CLI with `--output-format stream-json` and parse deltas.
|
|
* - Vision: best-effort direct API call using Qwen CLI's `~/.qwen/oauth_creds.json`.
|
|
*
|
|
* Notes:
|
|
* - We intentionally do NOT depend on the legacy `bin/auth.js` flow for normal chat.
|
|
* - If auth is missing, instruct the user to run Qwen CLI and `/auth`.
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { createRequire } from 'module';
|
|
import { fetchWithRetry } from './lib/retry-handler.mjs';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
let config = {};
|
|
try {
|
|
config = require('./config.cjs');
|
|
if (config.default) config = config.default;
|
|
} catch {
|
|
config = {};
|
|
}
|
|
|
|
const QWEN_OAUTH_CLIENT_ID = config.QWEN_OAUTH_CLIENT_ID || null;
|
|
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
|
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
|
|
|
const QWEN_CHAT_API = 'https://chat.qwen.ai/api/v1/chat/completions';
|
|
|
|
const stripAnsi = (input) => String(input || '').replace(
|
|
/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
|
''
|
|
);
|
|
|
|
const randomUUID = () => crypto.randomUUID();
|
|
|
|
const getOauthCredPath = () => path.join(os.homedir(), '.qwen', 'oauth_creds.json');
|
|
|
|
const normalizeModel = (model) => {
|
|
const m = String(model || '').trim();
|
|
// OpenQode/Goose use friendly IDs; Qwen portal currently accepts "coder-model".
|
|
// Keep this mapping centralized so all launch modes behave the same.
|
|
const map = {
|
|
'qwen-coder-plus': 'coder-model',
|
|
'qwen-plus': 'coder-model',
|
|
'qwen-turbo': 'coder-model',
|
|
'coder-model': 'coder-model',
|
|
};
|
|
return map[m] || 'coder-model';
|
|
};
|
|
|
|
/**
|
|
* Get the Qwen CLI command (local or global installation)
|
|
*/
|
|
const getQwenCommand = () => {
|
|
const isWin = process.platform === 'win32';
|
|
// Check for local installation first
|
|
const localPath = path.join(path.dirname(import.meta.url.replace('file:///', '')), 'node_modules', '.bin', isWin ? 'qwen.cmd' : 'qwen');
|
|
if (fs.existsSync(localPath)) {
|
|
return localPath;
|
|
}
|
|
// Fall back to global
|
|
return isWin ? 'qwen.cmd' : 'qwen';
|
|
};
|
|
|
|
class QwenOAuth {
|
|
constructor() {
|
|
this.tokens = null;
|
|
}
|
|
|
|
async loadTokens() {
|
|
const tokenPath = getOauthCredPath();
|
|
if (!fs.existsSync(tokenPath)) {
|
|
this.tokens = null;
|
|
return null;
|
|
}
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
|
if (!data?.access_token) {
|
|
this.tokens = null;
|
|
return null;
|
|
}
|
|
this.tokens = {
|
|
access_token: data.access_token,
|
|
refresh_token: data.refresh_token,
|
|
token_type: data.token_type || 'Bearer',
|
|
expiry_date: Number(data.expiry_date || 0),
|
|
resource_url: data.resource_url,
|
|
_tokenPath: tokenPath,
|
|
};
|
|
return this.tokens;
|
|
} catch {
|
|
this.tokens = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
isTokenValid() {
|
|
const expiry = Number(this.tokens?.expiry_date || 0);
|
|
if (!expiry) return true;
|
|
return expiry > Date.now() + 30_000;
|
|
}
|
|
|
|
async refreshToken() {
|
|
await this.loadTokens();
|
|
if (!this.tokens?.refresh_token) return false;
|
|
if (!QWEN_OAUTH_CLIENT_ID) return false;
|
|
|
|
const body = new URLSearchParams();
|
|
body.set('grant_type', 'refresh_token');
|
|
body.set('refresh_token', this.tokens.refresh_token);
|
|
body.set('client_id', QWEN_OAUTH_CLIENT_ID);
|
|
|
|
const resp = await fetchWithRetry(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
'x-request-id': randomUUID(),
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!resp.ok) return false;
|
|
const data = await resp.json().catch(() => null);
|
|
if (!data?.access_token) return false;
|
|
|
|
const tokenPath = this.tokens?._tokenPath || getOauthCredPath();
|
|
const next = {
|
|
access_token: data.access_token,
|
|
token_type: data.token_type || this.tokens.token_type || 'Bearer',
|
|
refresh_token: data.refresh_token || this.tokens.refresh_token,
|
|
resource_url: data.resource_url || this.tokens.resource_url,
|
|
expiry_date: data.expiry_date || (Date.now() + Number(data.expires_in || 3600) * 1000),
|
|
};
|
|
try {
|
|
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
|
fs.writeFileSync(tokenPath, JSON.stringify(next, null, 2));
|
|
} catch {
|
|
// ignore write errors; token is still usable in-memory
|
|
}
|
|
this.tokens = { ...next, _tokenPath: tokenPath };
|
|
return true;
|
|
}
|
|
|
|
async checkAuth() {
|
|
await this.loadTokens();
|
|
if (this.tokens?.access_token && this.isTokenValid()) {
|
|
return { authenticated: true, method: 'qwen-cli-oauth', hasVisionSupport: true };
|
|
}
|
|
|
|
// Fallback: check if qwen CLI exists (but token may be missing).
|
|
const { spawn } = await import('child_process');
|
|
const cmd = getQwenCommand();
|
|
const isWin = process.platform === 'win32';
|
|
return await new Promise((resolve) => {
|
|
const child = spawn(cmd, ['--version'], { shell: isWin, timeout: 5000, windowsHide: true });
|
|
child.on('error', () => resolve({ authenticated: false, reason: 'qwen CLI not available' }));
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve({ authenticated: false, reason: 'qwen CLI not authenticated', method: 'qwen-cli' });
|
|
} else {
|
|
resolve({ authenticated: false, reason: 'qwen CLI not available' });
|
|
}
|
|
});
|
|
setTimeout(() => { try { child.kill(); } catch { } resolve({ authenticated: false, reason: 'CLI check timeout' }); }, 5000);
|
|
});
|
|
}
|
|
|
|
async sendMessage(message, model = 'qwen-coder-plus', imageData = null, onChunk = null, systemPrompt = null) {
|
|
if (imageData) {
|
|
return await this.sendVisionMessage(message, imageData, 'qwen-vl-plus');
|
|
}
|
|
|
|
await this.loadTokens();
|
|
if (!this.tokens?.access_token) {
|
|
return {
|
|
success: false,
|
|
error: 'Not authenticated. Open option [4] and run /auth.',
|
|
response: '',
|
|
};
|
|
}
|
|
|
|
if (!this.isTokenValid()) {
|
|
const ok = await this.refreshToken();
|
|
if (!ok) {
|
|
return {
|
|
success: false,
|
|
error: 'Token expired. Open option [4] and run /auth.',
|
|
response: '',
|
|
};
|
|
}
|
|
}
|
|
|
|
const messages = [];
|
|
if (systemPrompt) messages.push({ role: 'system', content: String(systemPrompt) });
|
|
messages.push({ role: 'user', content: String(message ?? '') });
|
|
|
|
const requestBody = {
|
|
model: normalizeModel(model),
|
|
messages,
|
|
stream: Boolean(onChunk),
|
|
};
|
|
|
|
const apiEndpoint = this.tokens?.resource_url
|
|
? `https://${this.tokens.resource_url}/v1/chat/completions`
|
|
: QWEN_CHAT_API;
|
|
|
|
try {
|
|
const response = await fetch(apiEndpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.tokens.access_token}`,
|
|
'x-request-id': randomUUID(),
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '');
|
|
return { success: false, error: `API error: ${response.status} ${errorText}`.trim(), response: '' };
|
|
}
|
|
|
|
if (onChunk) {
|
|
const reader = response.body?.getReader?.();
|
|
if (!reader) {
|
|
const data = await response.json().catch(() => ({}));
|
|
const text = data.choices?.[0]?.message?.content || '';
|
|
return { success: true, response: String(text) };
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let full = '';
|
|
let lastSig = '';
|
|
|
|
// Minimal line-based SSE parser for OpenAI-compatible streaming:
|
|
// data: {"choices":[{"delta":{"content":"..."}}]}
|
|
const emitFromDataLine = (dataLine) => {
|
|
const s = String(dataLine || '').trim();
|
|
if (!s) return;
|
|
if (s === '[DONE]') return;
|
|
let obj;
|
|
try { obj = JSON.parse(s); } catch { return; }
|
|
const id = String(obj?.id || '');
|
|
const index = String(obj?.choices?.[0]?.index ?? 0);
|
|
const delta = obj?.choices?.[0]?.delta?.content;
|
|
if (typeof delta === 'string' && delta.length) {
|
|
const sig = `${id}:${index}:${delta}`;
|
|
// Some Qwen portal streams repeat identical delta frames; ignore exact repeats.
|
|
if (sig === lastSig) return;
|
|
lastSig = sig;
|
|
full += delta;
|
|
onChunk(delta);
|
|
}
|
|
};
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let idx;
|
|
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
const line = buffer.slice(0, idx);
|
|
buffer = buffer.slice(idx + 1);
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('data:')) continue;
|
|
emitFromDataLine(trimmed.slice(5).trim());
|
|
}
|
|
}
|
|
|
|
// Flush remaining buffer (best-effort).
|
|
const remaining = buffer.trim();
|
|
if (remaining.startsWith('data:')) emitFromDataLine(remaining.slice(5).trim());
|
|
|
|
return { success: true, response: full };
|
|
}
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
const responseText = data.choices?.[0]?.message?.content || '';
|
|
return { success: true, response: String(responseText), usage: data.usage };
|
|
} catch (e) {
|
|
return { success: false, error: e?.message || String(e), response: '' };
|
|
}
|
|
}
|
|
|
|
async getAccessToken() {
|
|
await this.loadTokens();
|
|
if (!this.tokens?.access_token) throw new Error('Not authenticated');
|
|
if (!this.isTokenValid()) {
|
|
const ok = await this.refreshToken();
|
|
if (!ok) throw new Error('Token expired. Re-auth in Qwen CLI using /auth.');
|
|
}
|
|
return this.tokens.access_token;
|
|
}
|
|
|
|
async sendVisionMessage(message, imageData, model = 'qwen-vl-plus') {
|
|
try {
|
|
const accessToken = await this.getAccessToken();
|
|
|
|
const content = [];
|
|
if (imageData) {
|
|
content.push({
|
|
type: 'image_url',
|
|
image_url: { url: imageData },
|
|
});
|
|
}
|
|
content.push({ type: 'text', text: String(message ?? '') });
|
|
|
|
const requestBody = {
|
|
model,
|
|
messages: [{ role: 'user', content }],
|
|
stream: false,
|
|
};
|
|
|
|
const response = await fetchWithRetry(QWEN_CHAT_API, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'x-request-id': randomUUID(),
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '');
|
|
return { success: false, error: `Vision API error: ${response.status} ${errorText}`.trim(), response: '' };
|
|
}
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
const responseText = data.choices?.[0]?.message?.content || '';
|
|
return { success: true, response: responseText, usage: data.usage };
|
|
} catch (e) {
|
|
return { success: false, error: e?.message || String(e), response: '' };
|
|
}
|
|
}
|
|
}
|
|
|
|
export { QwenOAuth };
|