193 lines
5.7 KiB
JavaScript
193 lines
5.7 KiB
JavaScript
/**
|
|
* Qwen API Bridge for Goose Ultra
|
|
*
|
|
* Uses the SAME token infrastructure as QwenOAuth (qwen-oauth.mjs)
|
|
* Token location: ~/.qwen/oauth_creds.json
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import https from 'https';
|
|
import os from 'os';
|
|
import crypto from 'crypto';
|
|
|
|
const QWEN_CHAT_API = 'https://chat.qwen.ai/api/v1/chat/completions';
|
|
|
|
const getOauthCredPath = () => path.join(os.homedir(), '.qwen', 'oauth_creds.json');
|
|
|
|
const normalizeModel = (model) => {
|
|
const m = String(model || '').trim();
|
|
const map = {
|
|
'qwen-coder-plus': 'coder-model',
|
|
'qwen-plus': 'coder-model',
|
|
'qwen-turbo': 'coder-model',
|
|
'coder-model': 'coder-model',
|
|
};
|
|
return map[m] || 'coder-model';
|
|
};
|
|
|
|
export function loadTokens() {
|
|
const tokenPath = getOauthCredPath();
|
|
try {
|
|
if (fs.existsSync(tokenPath)) {
|
|
const data = JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
|
|
if (data.access_token) {
|
|
console.log('[QwenAPI] Loaded tokens from:', tokenPath);
|
|
return {
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[QwenAPI] Token load error:', e.message);
|
|
}
|
|
console.warn('[QwenAPI] No valid tokens found at', tokenPath);
|
|
return null;
|
|
}
|
|
|
|
function isTokenValid(tokens) {
|
|
const expiry = Number(tokens?.expiry_date || 0);
|
|
if (!expiry) return true;
|
|
return expiry > Date.now() + 30_000;
|
|
}
|
|
|
|
function getApiEndpoint(tokens) {
|
|
if (tokens?.resource_url) {
|
|
return `https://${tokens.resource_url}/v1/chat/completions`;
|
|
}
|
|
return QWEN_CHAT_API;
|
|
}
|
|
|
|
// Track active request to prevent stream interleaving
|
|
let activeRequest = null;
|
|
|
|
export function abortActiveChat() {
|
|
if (activeRequest) {
|
|
console.log('[QwenAPI] Aborting previous request...');
|
|
try {
|
|
activeRequest.destroy();
|
|
} catch (e) {
|
|
console.warn('[QwenAPI] Abort warning:', e.message);
|
|
}
|
|
activeRequest = null;
|
|
}
|
|
}
|
|
|
|
export async function streamChat(messages, model = 'qwen-coder-plus', onChunk, onComplete, onError, onStatus) {
|
|
// Abort any existing request to prevent interleaving
|
|
abortActiveChat();
|
|
|
|
const log = (msg) => {
|
|
console.log('[QwenAPI]', msg);
|
|
if (onStatus) onStatus(msg);
|
|
};
|
|
|
|
log('Loading tokens...');
|
|
const tokens = loadTokens();
|
|
|
|
if (!tokens?.access_token) {
|
|
log('Error: No tokens found.');
|
|
console.error('[QwenAPI] Authentication missing. No valid tokens found.');
|
|
onError(new Error('AUTHENTICATION_REQUIRED: Please run OpenQode > Option 4, then /auth in Qwen CLI.'));
|
|
return;
|
|
}
|
|
|
|
if (!isTokenValid(tokens)) {
|
|
log('Error: Tokens expired.');
|
|
console.error('[QwenAPI] Token expired.');
|
|
onError(new Error('TOKEN_EXPIRED: Please run OpenQode > Option 4 and /auth again.'));
|
|
return;
|
|
}
|
|
|
|
const endpoint = getApiEndpoint(tokens);
|
|
const url = new URL(endpoint);
|
|
const requestId = crypto.randomUUID();
|
|
|
|
const body = JSON.stringify({
|
|
model: normalizeModel(model),
|
|
messages: messages,
|
|
stream: true
|
|
});
|
|
|
|
log(`Connecting to ${url.hostname}...`);
|
|
console.log(`[QwenAPI] Calling ${url.href} with model ${normalizeModel(model)}`);
|
|
|
|
const options = {
|
|
hostname: url.hostname,
|
|
port: 443,
|
|
path: url.pathname,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${tokens.access_token}`,
|
|
'x-request-id': requestId,
|
|
'Content-Length': Buffer.byteLength(body)
|
|
}
|
|
};
|
|
|
|
const req = https.request(options, (res) => {
|
|
activeRequest = req;
|
|
let fullResponse = '';
|
|
|
|
if (res.statusCode !== 200) {
|
|
let errBody = '';
|
|
res.on('data', (c) => errBody += c.toString());
|
|
res.on('end', () => {
|
|
onError(new Error(`API Error ${res.statusCode}: ${errBody}`));
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.setEncoding('utf8');
|
|
let buffer = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
buffer += chunk;
|
|
|
|
// split by double newline or newline
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop(); // Keep incomplete line
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
// Check prefix
|
|
if (!trimmed.startsWith('data: ')) continue;
|
|
|
|
const data = trimmed.replace('data: ', '').trim();
|
|
if (data === '[DONE]') {
|
|
onComplete(fullResponse);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
// Qwen strict response matching
|
|
const choice = parsed.choices?.[0];
|
|
const content = choice?.delta?.content || choice?.message?.content || '';
|
|
|
|
if (content) {
|
|
fullResponse += content;
|
|
onChunk(content);
|
|
}
|
|
} catch (e) {
|
|
// Ignore parsing errors for intermediate crumbs
|
|
}
|
|
}
|
|
});
|
|
|
|
res.on('end', () => {
|
|
onComplete(fullResponse);
|
|
});
|
|
});
|
|
|
|
req.on('error', (e) => {
|
|
console.error('[QwenAPI] Request error:', e.message);
|
|
onError(e);
|
|
});
|
|
req.write(body);
|
|
req.end();
|
|
}
|