diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0d5a26..d24c4bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -391,7 +391,7 @@ model ScheduledJob { model BookDateConfig { id String @id @default(uuid()) - provider String // 'openai' | 'claude' | 'custom' + provider String // 'openai' | 'claude' | 'gemini' | 'custom' apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256) model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929' baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints) diff --git a/src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx b/src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx index 6b1aade..c842aa6 100644 --- a/src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx +++ b/src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx @@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) { > + @@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) { ? 'Leave blank for local models' : configured ? '••••••••••••••••' - : (provider === 'openai' ? 'sk-...' : 'sk-ant-...') + : (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...') } />
diff --git a/src/app/api/bookdate/config/route.ts b/src/app/api/bookdate/config/route.ts index 393223d..21e0e96 100644 --- a/src/app/api/bookdate/config/route.ts +++ b/src/app/api/bookdate/config/route.ts @@ -59,9 +59,9 @@ async function saveConfig(req: AuthenticatedRequest) { ); } - if (!['openai', 'claude', 'custom'].includes(provider)) { + if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) { return NextResponse.json( - { error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, + { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' }, { status: 400 } ); } @@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) { // No new API key, use existing one encryptedApiKeyToUse = existingConfig.apiKey; } else { - // API key required for OpenAI/Claude + // API key required for OpenAI/Claude/Gemini return NextResponse.json( { error: 'API key is required' }, { status: 400 } diff --git a/src/app/api/bookdate/test-connection/route.ts b/src/app/api/bookdate/test-connection/route.ts index 6cd491d..0ba24c0 100644 --- a/src/app/api/bookdate/test-connection/route.ts +++ b/src/app/api/bookdate/test-connection/route.ts @@ -52,6 +52,29 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st return allModels; } +// Fetch available Gemini models from the Google API +async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}` + ); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Gemini API error', { error: errorText }); + throw new Error('Invalid Gemini API key or connection failed'); + } + + const data = await response.json(); + + return (data.models || []) + .filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent')) + .map((m: any) => ({ + id: m.name.replace('models/', ''), + name: m.displayName || m.name.replace('models/', ''), + })) + .sort((a: any, b: any) => a.name.localeCompare(b.name)); +} + // Helper functions for custom provider function isValidBaseUrl(url: string): boolean { try { @@ -79,9 +102,9 @@ async function authenticatedHandler(req: AuthenticatedRequest) { ); } - if (!['openai', 'claude', 'custom'].includes(provider)) { + if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) { return NextResponse.json( - { error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, + { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' }, { status: 400 } ); } @@ -193,6 +216,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) { { status: 400 } ); } + } else if (provider === 'gemini') { + // Gemini: Fetch models dynamically from the Google API + try { + models = await fetchGeminiModels(testApiKey); + } catch { + return NextResponse.json( + { error: 'Invalid Gemini API key or connection failed' }, + { status: 400 } + ); + } } else if (provider === 'custom') { // Custom: Fetch models from custom OpenAI-compatible endpoint const normalizedUrl = normalizeBaseUrl(testBaseUrl); @@ -291,9 +324,9 @@ async function unauthenticatedHandler(req: NextRequest) { ); } - if (!['openai', 'claude', 'custom'].includes(provider)) { + if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) { return NextResponse.json( - { error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, + { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' }, { status: 400 } ); } @@ -363,6 +396,16 @@ async function unauthenticatedHandler(req: NextRequest) { { status: 400 } ); } + } else if (provider === 'gemini') { + // Gemini: Fetch models dynamically + try { + models = await fetchGeminiModels(apiKey); + } catch { + return NextResponse.json( + { error: 'Invalid Gemini API key or connection failed' }, + { status: 400 } + ); + } } else if (provider === 'custom') { // Custom: Fetch models from custom OpenAI-compatible endpoint const normalizedUrl = normalizeBaseUrl(baseUrl); diff --git a/src/app/setup/steps/BookDateStep.tsx b/src/app/setup/steps/BookDateStep.tsx index ea1a6ac..4c65436 100644 --- a/src/app/setup/steps/BookDateStep.tsx +++ b/src/app/setup/steps/BookDateStep.tsx @@ -134,6 +134,7 @@ export function BookDateStep({ > + @@ -152,7 +153,7 @@ export function BookDateStep({ onUpdate('bookdateConfigured', false); onUpdate('bookdateModels', []); }} - placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} + placeholder={bookdateProvider === 'openai' ? 'sk-...' : bookdateProvider === 'gemini' ? 'AIza...' : 'sk-ant-...'} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
diff --git a/src/lib/bookdate/helpers.ts b/src/lib/bookdate/helpers.ts index 77e58ab..5faf3a8 100644 --- a/src/lib/bookdate/helpers.ts +++ b/src/lib/bookdate/helpers.ts @@ -691,6 +691,73 @@ export async function callAI( logger.debug('Claude cleaned response:', { cleanedContent }); return JSON.parse(cleanedContent); + } else if (provider === 'gemini') { + const requestBody = { + systemInstruction: { + parts: [{ text: systemMessage }], + }, + contents: [ + { + parts: [{ text: prompt }], + }, + ], + generationConfig: { + responseMimeType: "application/json", + responseSchema: { + type: "OBJECT", + properties: { + recommendations: { + type: "ARRAY", + items: { + type: "OBJECT", + properties: { + title: { type: "STRING" }, + author: { type: "STRING" }, + reason: { type: "STRING" }, + }, + required: ["title", "author", "reason"], + }, + }, + }, + required: ["recommendations"], + }, + }, + }; + + logger.debug('Gemini request body:', { requestBody }); + + const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Gemini API error', { status: response.status, error: errorText }); + throw new Error(`Gemini API error: ${response.status} ${errorText}`); + } + + const data = await response.json(); + const content = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!content) { + throw new Error('Invalid response format from Gemini API'); + } + + logger.debug('Gemini raw response:', { content }); + + // Clean potential markdown wrapping + const cleanedContent = content + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/i, '') + .trim(); + + logger.debug('Gemini cleaned response:', { cleanedContent }); + return JSON.parse(cleanedContent); + } else if (provider === 'custom') { if (!baseUrl) { throw new Error('Base URL is required for custom provider');