Add custom AI provider support and improve qBittorrent auth

Introduces support for custom OpenAI-compatible AI providers with configurable base URLs, including UI, backend validation, and connection testing. Enhances qBittorrent integration to support HTTP Basic Auth for reverse proxies, adds detailed debug logging, and updates documentation for both features. Also improves login page description logic and AI prompt generation for recommendations.
This commit is contained in:
kikootwo
2026-01-12 17:11:39 -05:00
parent 682836237b
commit 50fb5a68af
13 changed files with 664 additions and 74 deletions
+155 -24
View File
@@ -448,25 +448,17 @@ export async function buildAIPrompt(
custom_preferences: config.customPrompt || null,
},
instructions:
'Based on the user\'s library and swipe history, recommend 20 audiobooks they would enjoy. ' +
'Important rules:\n' +
'1. DO NOT recommend any books already in the user\'s library\n' +
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
'CRITICAL RULES:\n' +
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
'3. Focus on variety and quality\n' +
'4. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
'5. Learn from rejected books to avoid similar recommendations\n' +
'6. Learn from requested books to find similar ones\n' +
'7. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
'Return ONLY valid JSON with no additional text or formatting.',
response_format: {
recommendations: [
{
title: 'string',
author: 'string',
reason: '1-2 sentence explanation',
},
],
},
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
'4. Focus on variety across genres, authors, and styles\n' +
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
'6. Learn from rejected books to avoid similar recommendations\n' +
'7. Learn from requested books to find similar ones\n' +
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context',
};
const promptString = JSON.stringify(prompt);
@@ -487,18 +479,62 @@ export async function callAI(
provider: string,
model: string,
encryptedApiKey: string,
prompt: string
prompt: string,
baseUrl?: string | null
): Promise<{ recommendations: AIRecommendation[] }> {
const encryptionService = getEncryptionService();
const apiKey = encryptionService.decrypt(encryptedApiKey);
let apiKey = '';
try {
apiKey = encryptionService.decrypt(encryptedApiKey);
} catch (error) {
// Allow empty API key for custom provider (local models)
if (provider !== 'custom') {
throw error;
}
}
logger.info(`Calling AI provider: ${provider}, model: ${model}`);
// Define JSON schema for structured output
const responseSchema = {
type: 'json_schema',
json_schema: {
name: 'audiobook_recommendations',
strict: true,
schema: {
type: 'object',
properties: {
recommendations: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
author: { type: 'string' },
reason: { type: 'string' },
},
required: ['title', 'author', 'reason'],
additionalProperties: false,
},
minItems: 15,
maxItems: 20,
},
},
required: ['recommendations'],
additionalProperties: false,
},
},
};
const systemMessage = 'You are an expert audiobook recommender. ' +
'Your task is to recommend 15-20 NEW audiobooks that the user would enjoy. ' +
'NEVER recommend books that are already in the user\'s library or swipe history. ' +
'Focus on discovering books they haven\'t seen yet.';
if (provider === 'openai') {
const systemMessage = 'You are an expert audiobook recommender. Analyze user preferences and suggest audiobooks they will love. Return ONLY valid JSON.';
const requestBody = {
model,
response_format: { type: 'json_object' },
response_format: responseSchema,
messages: [
{
role: 'system',
@@ -534,10 +570,10 @@ export async function callAI(
return JSON.parse(content);
} else if (provider === 'claude') {
const userMessage = `${prompt}\n\nReturn ONLY valid JSON with no additional text or formatting.`;
const userMessage = `${systemMessage}\n\n${prompt}\n\nIMPORTANT: Provide exactly 15-20 recommendations. Return ONLY valid JSON with no additional text or formatting.`;
const requestBody = {
model,
max_tokens: 4096,
max_tokens: 8192,
messages: [
{
role: 'user',
@@ -577,6 +613,101 @@ export async function callAI(
logger.debug('Claude cleaned response:', { cleanedContent });
return JSON.parse(cleanedContent);
} else if (provider === 'custom') {
if (!baseUrl) {
throw new Error('Base URL is required for custom provider');
}
// Try with json_schema first
let requestBody: any = {
model,
response_format: responseSchema,
messages: [
{
role: 'system',
content: systemMessage,
},
{
role: 'user',
content: prompt,
},
],
};
logger.debug('Custom provider request body:', { requestBody, baseUrl });
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Only add Authorization header if API key provided
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const endpoint = baseUrl.replace(/\/$/, '') + '/chat/completions';
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Custom provider API error', { status: response.status, error: errorText });
// If response_format not supported, retry without it and add instructions to prompt
if (errorText.includes('response_format') || errorText.includes('json_schema')) {
logger.info('Retrying without response_format (provider does not support structured outputs)');
delete requestBody.response_format;
requestBody.messages[0].content = systemMessage + ' Return ONLY valid JSON with no additional text or formatting.';
const retryResponse = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
if (!retryResponse.ok) {
const retryErrorText = await retryResponse.text();
throw new Error(`Custom provider API error: ${retryResponse.status} ${retryErrorText}`);
}
const retryData = await retryResponse.json();
const retryContent = retryData.choices[0].message.content;
// Clean markdown code blocks
const cleanedContent = retryContent
.replace(/^```json\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
logger.debug('Custom provider cleaned response (fallback):', { cleanedContent });
return JSON.parse(cleanedContent);
}
throw new Error(`Custom provider API error: ${response.status} ${errorText}`);
}
const data = await response.json();
const content = data.choices[0].message.content;
logger.debug('Custom provider response:', { content });
// Clean potential markdown wrapping (some providers still wrap even with json_schema)
const cleanedContent = content
.replace(/^```json\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
return JSON.parse(cleanedContent);
} catch (error: any) {
logger.error('Custom provider error:', error);
throw new Error(`Custom provider error: ${error.message}`);
}
} else {
throw new Error(`Invalid provider: ${provider}`);
}
+118 -10
View File
@@ -115,6 +115,11 @@ export class QBittorrentService {
baseURL: `${this.baseUrl}/api/v2`,
timeout: 30000,
httpsAgent: this.httpsAgent,
// Support nginx/Apache reverse proxy with HTTP Basic Auth
auth: {
username: this.username,
password: this.password,
},
});
}
@@ -122,9 +127,20 @@ export class QBittorrentService {
* Authenticate and establish session
*/
async login(): Promise<void> {
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
logger.debug('[QBittorrent] Attempting login', {
url: loginUrl,
baseUrl: this.baseUrl,
username: this.username,
hasPassword: !!this.password,
passwordLength: this.password?.length,
sslVerifyDisabled: this.disableSSLVerify,
});
try {
const response = await axios.post(
`${this.baseUrl}/api/v2/auth/login`,
loginUrl,
new URLSearchParams({
username: this.username,
password: this.password,
@@ -136,22 +152,52 @@ export class QBittorrentService {
'Origin': this.baseUrl,
},
httpsAgent: this.httpsAgent,
// Support nginx/Apache reverse proxy with HTTP Basic Auth
auth: {
username: this.username,
password: this.password,
},
}
);
logger.debug('[QBittorrent] Login response received', {
status: response.status,
statusText: response.statusText,
data: response.data,
hasSetCookie: !!response.headers['set-cookie'],
setCookieCount: response.headers['set-cookie']?.length || 0,
});
// Extract cookie from response
const cookies = response.headers['set-cookie'];
if (cookies && cookies.length > 0) {
this.cookie = cookies[0].split(';')[0];
logger.debug('[QBittorrent] Cookie extracted', {
cookieName: this.cookie.split('=')[0],
cookieLength: this.cookie.length,
});
}
if (!this.cookie) {
logger.error('[QBittorrent] No cookie received in response');
throw new Error('Failed to authenticate with qBittorrent');
}
logger.info('Successfully authenticated');
} catch (error) {
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
if (axios.isAxiosError(error)) {
logger.error('[QBittorrent] Login failed with axios error', {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: error.config?.url,
requestHeaders: error.config?.headers,
});
} else {
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
}
throw new Error('Failed to authenticate with qBittorrent');
}
}
@@ -687,6 +733,7 @@ export class QBittorrentService {
disableSSLVerify: boolean = false
): Promise<string> {
const baseUrl = url.replace(/\/$/, '');
const loginUrl = `${baseUrl}/api/v2/auth/login`;
// Create HTTPS agent if SSL verification is disabled
let httpsAgent: https.Agent | undefined;
@@ -697,36 +744,97 @@ export class QBittorrentService {
logger.info('[QBittorrent] SSL certificate verification disabled for test connection');
}
logger.debug('[QBittorrent] Test connection attempt', {
loginUrl,
baseUrl,
username,
hasPassword: !!password,
passwordLength: password?.length,
sslVerifyDisabled: disableSSLVerify,
hasHttpsAgent: !!httpsAgent,
});
try {
const requestBody = new URLSearchParams({ username, password });
const requestHeaders = {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': baseUrl,
'Origin': baseUrl,
};
logger.debug('[QBittorrent] Sending login request', {
body: requestBody.toString(),
headers: requestHeaders,
});
const response = await axios.post(
`${baseUrl}/api/v2/auth/login`,
new URLSearchParams({ username, password }),
loginUrl,
requestBody,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': baseUrl,
'Origin': baseUrl,
},
headers: requestHeaders,
httpsAgent,
// Support nginx/Apache reverse proxy with HTTP Basic Auth
auth: {
username,
password,
},
}
);
logger.debug('[QBittorrent] Login response received', {
status: response.status,
statusText: response.statusText,
data: response.data,
hasSetCookie: !!response.headers['set-cookie'],
setCookieCount: response.headers['set-cookie']?.length || 0,
allHeaders: Object.keys(response.headers),
});
// Get version to confirm connection
const cookies = response.headers['set-cookie'];
if (!cookies || cookies.length === 0) {
logger.error('[QBittorrent] No cookies in response', {
responseHeaders: response.headers,
});
throw new Error('Failed to authenticate - no session cookie received');
}
const cookie = cookies[0].split(';')[0];
logger.debug('[QBittorrent] Cookie extracted', {
cookieName: cookie.split('=')[0],
cookieLength: cookie.length,
});
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
headers: { Cookie: cookie },
httpsAgent,
// Support nginx/Apache reverse proxy with HTTP Basic Auth
auth: {
username,
password,
},
});
logger.info('[QBittorrent] Version check successful', {
version: versionResponse.data,
});
return versionResponse.data || 'Connected';
} catch (error) {
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
if (axios.isAxiosError(error)) {
logger.error('[QBittorrent] Test connection failed with axios error', {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: error.config?.url,
requestHeaders: error.config?.headers,
responseHeaders: error.response?.headers,
});
} else {
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
}
// Enhanced error messages for common issues
if (axios.isAxiosError(error)) {