mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -18,6 +18,11 @@ export async function GET() {
|
||||
// Check if local login is disabled via environment variable
|
||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||
|
||||
// Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
|
||||
const indexerType = await configService.get('indexer.type');
|
||||
const prowlarrUrl = await configService.get('indexer.prowlarr_url');
|
||||
const automationEnabled = !!(indexerType || prowlarrUrl);
|
||||
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
// Audiobookshelf mode - check which auth methods are enabled
|
||||
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
|
||||
@@ -41,6 +46,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: oidcEnabled ? oidcProviderName : null,
|
||||
localLoginDisabled,
|
||||
automationEnabled,
|
||||
});
|
||||
} else {
|
||||
// Plex mode - check if local admin exists (setup admin)
|
||||
@@ -58,6 +64,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
automationEnabled,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -71,6 +78,7 @@ export async function GET() {
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
automationEnabled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,13 @@ async function getConfig(req: AuthenticatedRequest) {
|
||||
async function saveConfig(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, model, libraryScope, customPrompt, isEnabled } = body;
|
||||
const { provider, apiKey, model, baseUrl, libraryScope, customPrompt, isEnabled } = body;
|
||||
|
||||
// Check if config exists
|
||||
const existingConfig = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
// Validation - API key only required for new configs
|
||||
if (!existingConfig && !apiKey) {
|
||||
// Validation - API key only required for new configs (except custom provider)
|
||||
if (!existingConfig && !apiKey && provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required for initial setup' },
|
||||
{ status: 400 }
|
||||
@@ -59,13 +59,39 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Custom provider requires baseUrl
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL is required for custom provider' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid base URL. Must use http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid base URL format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which API key to use
|
||||
let encryptedApiKeyToUse: string;
|
||||
|
||||
@@ -73,13 +99,17 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
// New API key provided - encrypt it
|
||||
const encryptionService = getEncryptionService();
|
||||
encryptedApiKeyToUse = encryptionService.encrypt(apiKey);
|
||||
} else if (provider === 'custom' && !apiKey && !existingConfig) {
|
||||
// Custom provider with no API key (local model) - encrypt empty string
|
||||
const encryptionService = getEncryptionService();
|
||||
encryptedApiKeyToUse = encryptionService.encrypt('');
|
||||
} else if (existingConfig) {
|
||||
// No new API key, use existing one
|
||||
encryptedApiKeyToUse = existingConfig.apiKey;
|
||||
} else {
|
||||
// This shouldn't happen due to validation above, but just in case
|
||||
// API key required for OpenAI/Claude
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required for new configuration' },
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -100,6 +130,13 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
updateData.apiKey = encryptedApiKeyToUse;
|
||||
}
|
||||
|
||||
// Update or clear baseUrl based on provider
|
||||
if (provider === 'custom') {
|
||||
updateData.baseUrl = baseUrl;
|
||||
} else {
|
||||
updateData.baseUrl = null; // Clear baseUrl when switching away from custom
|
||||
}
|
||||
|
||||
config = await prisma.bookDateConfig.update({
|
||||
where: { id: existingConfig.id },
|
||||
data: updateData,
|
||||
@@ -111,6 +148,7 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
data: {
|
||||
provider,
|
||||
model,
|
||||
baseUrl: provider === 'custom' ? baseUrl : null,
|
||||
libraryScope: 'full', // Default value for backwards compatibility
|
||||
customPrompt: null,
|
||||
isEnabled: isEnabled !== undefined ? isEnabled : true,
|
||||
|
||||
@@ -59,7 +59,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
// Build prompt and call AI (same as recommendations endpoint, but doesn't check cache)
|
||||
logger.info('Force generating new recommendations for user', { userId });
|
||||
const prompt = await buildAIPrompt(userId, userPreferences);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt, config.baseUrl);
|
||||
|
||||
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
|
||||
throw new Error('Invalid AI response format: missing recommendations array');
|
||||
|
||||
@@ -80,7 +80,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
// Build prompt and call AI
|
||||
logger.info('Generating new recommendations for user', { userId });
|
||||
const prompt = await buildAIPrompt(userId, userPreferences);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt, config.baseUrl);
|
||||
|
||||
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
|
||||
throw new Error('Invalid AI response format: missing recommendations array');
|
||||
|
||||
@@ -9,10 +9,24 @@ import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
||||
|
||||
// Helper functions for custom provider
|
||||
function isValidBaseUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
return url.replace(/\/$/, ''); // Remove trailing slash
|
||||
}
|
||||
|
||||
async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, useSavedKey } = body;
|
||||
const { provider, apiKey, baseUrl, useSavedKey } = body;
|
||||
|
||||
// Validate provider
|
||||
if (!provider) {
|
||||
@@ -22,16 +36,35 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get API key from saved global config if useSavedKey is true
|
||||
// Custom provider requires baseUrl
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl && !useSavedKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL is required for custom provider' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const urlToValidate = useSavedKey ? null : baseUrl; // Will check saved URL later if useSavedKey
|
||||
if (urlToValidate && !isValidBaseUrl(urlToValidate)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid base URL format. Must start with http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get API key and baseUrl from saved global config if useSavedKey is true
|
||||
let testApiKey = apiKey;
|
||||
if (useSavedKey && !testApiKey) {
|
||||
let testBaseUrl = baseUrl;
|
||||
if (useSavedKey) {
|
||||
const { prisma } = await import('@/lib/db');
|
||||
const { getEncryptionService } = await import('@/lib/services/encryption.service');
|
||||
|
||||
@@ -39,16 +72,38 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
|
||||
if (!config || !config.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No saved API key found' },
|
||||
{ error: 'No saved configuration found' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
testApiKey = encryptionService.decrypt(config.apiKey);
|
||||
try {
|
||||
testApiKey = encryptionService.decrypt(config.apiKey);
|
||||
} catch {
|
||||
// Allow empty API key for custom provider
|
||||
if (provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to decrypt saved API key' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
testApiKey = '';
|
||||
}
|
||||
|
||||
if (provider === 'custom') {
|
||||
testBaseUrl = config.baseUrl || '';
|
||||
if (!testBaseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No saved base URL found for custom provider' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!testApiKey) {
|
||||
// API key required for OpenAI and Claude
|
||||
if (!testApiKey && provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
@@ -117,6 +172,64 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(testBaseUrl);
|
||||
const modelsEndpoint = normalizedUrl + '/models';
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (testApiKey) {
|
||||
headers['Authorization'] = `Bearer ${testApiKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(modelsEndpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Custom provider connection error', { error: errorText });
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle multiple response formats
|
||||
let modelsList = [];
|
||||
if (Array.isArray(data?.data)) {
|
||||
// OpenAI format: { data: [...] }
|
||||
modelsList = data.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
}));
|
||||
} else if (Array.isArray(data)) {
|
||||
// Direct array format
|
||||
modelsList = data.map((m: any) => ({
|
||||
id: m.id || m,
|
||||
name: m.name || m.id || m,
|
||||
}));
|
||||
} else {
|
||||
// Unable to parse, but connection successful
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models: [],
|
||||
message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
||||
});
|
||||
}
|
||||
|
||||
models = modelsList;
|
||||
} catch (error: any) {
|
||||
logger.error('Custom provider network error', { error: error.message });
|
||||
return NextResponse.json(
|
||||
{ error: `Network error connecting to custom provider: ${error.message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -138,7 +251,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
async function unauthenticatedHandler(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, useSavedKey } = body;
|
||||
const { provider, apiKey, baseUrl, useSavedKey } = body;
|
||||
|
||||
// During setup, useSavedKey should not be used (no auth context)
|
||||
if (useSavedKey) {
|
||||
@@ -156,14 +269,32 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
// Custom provider requires baseUrl
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL is required for custom provider' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidBaseUrl(baseUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid base URL format. Must start with http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// API key required for OpenAI and Claude
|
||||
if (!apiKey && provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
@@ -232,6 +363,64 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||||
const modelsEndpoint = normalizedUrl + '/models';
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(modelsEndpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Custom provider connection error', { error: errorText });
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle multiple response formats
|
||||
let modelsList = [];
|
||||
if (Array.isArray(data?.data)) {
|
||||
// OpenAI format: { data: [...] }
|
||||
modelsList = data.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
}));
|
||||
} else if (Array.isArray(data)) {
|
||||
// Direct array format
|
||||
modelsList = data.map((m: any) => ({
|
||||
id: m.id || m,
|
||||
name: m.name || m.id || m,
|
||||
}));
|
||||
} else {
|
||||
// Unable to parse, but connection successful
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models: [],
|
||||
message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
||||
});
|
||||
}
|
||||
|
||||
models = modelsList;
|
||||
} catch (error: any) {
|
||||
logger.error('Custom provider network error', { error: error.message });
|
||||
return NextResponse.json(
|
||||
{ error: `Network error connecting to custom provider: ${error.message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
Reference in New Issue
Block a user