Add gemini bookdate support

This commit is contained in:
Rob Walsh
2026-02-28 10:37:45 -07:00
parent 3ee67c8763
commit 038c92e49f
6 changed files with 122 additions and 10 deletions
+1 -1
View File
@@ -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)
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
<option value="custom">Custom (OpenAI-compatible)</option>
</select>
</div>
@@ -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-...')
}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
+3 -3
View File
@@ -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 }
+47 -4
View File
@@ -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);
+2 -1
View File
@@ -134,6 +134,7 @@ export function BookDateStep({
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
</select>
</div>
@@ -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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+67
View File
@@ -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');