diff --git a/backend/src/index.ts b/backend/src/index.ts index 050f706..95f111f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -447,10 +447,11 @@ CRITICAL RULES: const baseUrl = providerConfig.baseUrl || settings?.aiBaseUrl; const aiProvider = createAIProvider({ - provider: provider as 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq', + provider, apiKey: apiKey, model: model || undefined, - baseUrl: (provider === 'ollama' || provider === 'lmstudio') ? baseUrl : undefined, + baseUrl: (provider === 'ollama' || provider === 'lmstudio' || provider === 'custom') ? baseUrl : undefined, + providerSettings: settings?.providerSettings, }); console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`); @@ -657,7 +658,7 @@ app.post('/api/v1/ai/test', async (c) => { const userId = await getUserId(c); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); - const { provider, apiKey, model, baseUrl } = await c.req.json(); + const { provider, apiKey, model, baseUrl, headers, parameters } = await c.req.json(); console.log(`[AI Test] Provider: ${provider}, Model: ${model || 'default'}, BaseURL: ${baseUrl || 'default'}`); console.log(`[AI Test] API Key set: ${!!apiKey}, Length: ${apiKey?.length || 0}`); @@ -666,12 +667,18 @@ app.post('/api/v1/ai/test', async (c) => { return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'provider is required' } }, 400); } + let providerSettings: string | undefined; + if (headers || parameters) { + providerSettings = JSON.stringify({ headers, parameters }); + } + try { const aiProvider = createAIProvider({ provider, apiKey: apiKey || '', model: model || undefined, baseUrl: baseUrl || undefined, + providerSettings, }); console.log(`[AI Test] Creating provider: ${aiProvider.provider}`); diff --git a/backend/src/routes/journal.ts b/backend/src/routes/journal.ts index 87ddac2..bc9cc0b 100644 --- a/backend/src/routes/journal.ts +++ b/backend/src/routes/journal.ts @@ -28,10 +28,11 @@ journalRoutes.post('/generate/:date', async (c) => { } const provider = createAIProvider({ - provider: settings.aiProvider as AIProvider['provider'], + provider: settings.aiProvider, apiKey: settings.aiApiKey, model: settings.aiModel, baseUrl: settings.aiBaseUrl, + providerSettings: settings.providerSettings, }); const eventsText = events.map(event => { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index bfbd8bb..d60f87d 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -26,7 +26,7 @@ settingsRoutes.put('/', async (c) => { const prisma = c.get('prisma'); const body = await c.req.json(); - const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body; + const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings } = body; const data: Record = {}; if (aiProvider !== undefined) data.aiProvider = aiProvider; @@ -35,6 +35,7 @@ settingsRoutes.put('/', async (c) => { if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl; if (journalPrompt !== undefined) data.journalPrompt = journalPrompt; if (language !== undefined) data.language = language; + if (providerSettings !== undefined) data.providerSettings = typeof providerSettings === 'string' ? providerSettings : JSON.stringify(providerSettings); const settings = await prisma.settings.upsert({ where: { userId }, @@ -70,12 +71,17 @@ settingsRoutes.post('/validate-key', async (c) => { settingsRoutes.post('/test', async (c) => { const body = await c.req.json(); - const { provider, apiKey, model, baseUrl } = body; + const { provider, apiKey, model, baseUrl, headers, parameters } = body; if (!provider) { return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'provider is required' } }, 400); } + let providerSettings: string | undefined; + if (headers || parameters) { + providerSettings = JSON.stringify({ headers, parameters }); + } + const { createAIProvider } = await import('../services/ai/provider'); try { @@ -84,11 +90,12 @@ settingsRoutes.post('/test', async (c) => { apiKey: apiKey || '', model: model || undefined, baseUrl: baseUrl || undefined, + providerSettings, }); const result = await aiProvider.generate('Say "OK" if you can read this.', 'You are a test assistant. Respond with just "OK".'); - if (result.toLowerCase().includes('ok')) { + if (result.content.toLowerCase().includes('ok')) { return c.json({ data: { valid: true, message: 'Connection successful!' }, error: null }); } else { return c.json({ data: { valid: false }, error: { code: 'TEST_FAILED', message: 'Model responded but with unexpected output' } }); diff --git a/backend/src/services/ai/anthropic.ts b/backend/src/services/ai/anthropic.ts index 85a8df6..69555d8 100644 --- a/backend/src/services/ai/anthropic.ts +++ b/backend/src/services/ai/anthropic.ts @@ -1,15 +1,20 @@ import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider'; +import { ProviderSettings } from './openai'; export class AnthropicProvider implements AIProvider { provider = 'anthropic' as const; private apiKey: string; private model: string; private baseUrl: string; + private customHeaders?: Record; + private customParameters?: Record; - constructor(config: AIProviderConfig) { + constructor(config: AIProviderConfig, settings?: ProviderSettings) { this.apiKey = config.apiKey; this.model = config.model || 'claude-3-sonnet-20240229'; this.baseUrl = config.baseUrl || 'https://api.anthropic.com/v1'; + this.customHeaders = settings?.headers; + this.customParameters = settings?.parameters; } async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise { @@ -20,20 +25,24 @@ export class AnthropicProvider implements AIProvider { messages: [ { role: 'user', content: prompt } ], + ...this.customParameters, }; if (options?.jsonMode) { requestBody.output = { format: { type: 'json_object' } }; } + const headers: Record = { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + ...this.customHeaders, + }; + const response = await fetch(`${this.baseUrl}/messages`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01', - 'anthropic-dangerous-direct-browser-access': 'true', - }, + headers, body: JSON.stringify(requestBody), }); @@ -66,18 +75,21 @@ export class AnthropicProvider implements AIProvider { async validate(): Promise { try { + const headers: Record = { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + ...this.customHeaders, + }; const response = await fetch(`${this.baseUrl}/messages`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01', - 'anthropic-dangerous-direct-browser-access': 'true', - }, + headers, body: JSON.stringify({ model: this.model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }], + ...this.customParameters, }), }); return response.ok; diff --git a/backend/src/services/ai/custom.ts b/backend/src/services/ai/custom.ts new file mode 100644 index 0000000..8f7dd41 --- /dev/null +++ b/backend/src/services/ai/custom.ts @@ -0,0 +1,94 @@ +import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider'; +import { ProviderSettings } from './openai'; + +export class CustomProvider implements AIProvider { + provider = 'custom' as const; + private apiKey: string; + private model: string; + private baseUrl: string; + private customHeaders?: Record; + private customParameters?: Record; + + constructor(config: AIProviderConfig, settings?: ProviderSettings) { + this.apiKey = config.apiKey || ''; + this.model = config.model || 'unknown'; + this.baseUrl = config.baseUrl || ''; + this.customHeaders = settings?.headers; + this.customParameters = settings?.parameters; + } + + async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise { + const messages: Array<{ role: string; content: string }> = []; + + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + + messages.push({ role: 'user', content: prompt }); + + const requestBody: Record = { + model: this.model, + messages, + temperature: 0.7, + max_tokens: 2000, + ...this.customParameters, + }; + + if (options?.jsonMode) { + requestBody.response_format = { type: 'json_object' }; + } + + const headers: Record = { + 'Content-Type': 'application/json', + ...(this.apiKey ? { 'Authorization': `Bearer ${this.apiKey}` } : {}), + ...this.customHeaders, + }; + + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + }); + + const responseData = await response.json(); + + if (!response.ok) { + throw new Error(`Custom API error: ${response.status} ${JSON.stringify(responseData)}`); + } + + let content = responseData.choices?.[0]?.message?.content || ''; + let title: string | undefined; + + if (options?.jsonMode) { + try { + const parsed = JSON.parse(content); + title = parsed.title; + content = parsed.content || content; + } catch { + // If JSON parsing fails, use content as-is + } + } + + return { + content, + title, + request: requestBody, + response: responseData, + }; + } + + async validate(): Promise { + try { + if (!this.baseUrl) return false; + const response = await fetch(`${this.baseUrl}/models`, { + headers: { + ...(this.apiKey ? { 'Authorization': `Bearer ${this.apiKey}` } : {}), + ...this.customHeaders, + }, + }); + return response.ok; + } catch { + return false; + } + } +} diff --git a/backend/src/services/ai/groq.ts b/backend/src/services/ai/groq.ts index 9b7a38a..933d066 100644 --- a/backend/src/services/ai/groq.ts +++ b/backend/src/services/ai/groq.ts @@ -1,15 +1,20 @@ import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider'; +import { ProviderSettings } from './openai'; export class GroqProvider implements AIProvider { provider = 'groq' as const; private apiKey: string; private model: string; private baseUrl: string; + private customHeaders?: Record; + private customParameters?: Record; - constructor(config: AIProviderConfig) { + constructor(config: AIProviderConfig, settings?: ProviderSettings) { this.apiKey = config.apiKey; this.model = config.model || 'llama-3.3-70b-versatile'; this.baseUrl = config.baseUrl || 'https://api.groq.com/openai/v1'; + this.customHeaders = settings?.headers; + this.customParameters = settings?.parameters; } async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise { @@ -26,18 +31,22 @@ export class GroqProvider implements AIProvider { messages, temperature: 0.7, max_tokens: 2000, + ...this.customParameters, }; if (options?.jsonMode) { requestBody.response_format = { type: 'json_object' }; } + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + ...this.customHeaders, + }; + const response = await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, + headers, body: JSON.stringify(requestBody), }); @@ -71,16 +80,19 @@ export class GroqProvider implements AIProvider { async validate(): Promise { try { + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + ...this.customHeaders, + }; const response = await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, + headers, body: JSON.stringify({ model: this.model, messages: [{ role: 'user', content: 'test' }], max_tokens: 5, + ...this.customParameters, }), }); return response.ok || response.status === 400; diff --git a/backend/src/services/ai/lmstudio.ts b/backend/src/services/ai/lmstudio.ts index fa143fa..f345f18 100644 --- a/backend/src/services/ai/lmstudio.ts +++ b/backend/src/services/ai/lmstudio.ts @@ -1,13 +1,18 @@ import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider'; +import { ProviderSettings } from './openai'; export class LMStudioProvider implements AIProvider { provider = 'lmstudio' as const; private baseUrl: string; private model: string; + private customHeaders?: Record; + private customParameters?: Record; - constructor(config: AIProviderConfig) { + constructor(config: AIProviderConfig, settings?: ProviderSettings) { this.baseUrl = config.baseUrl || 'http://localhost:1234/v1'; this.model = config.model || 'local-model'; + this.customHeaders = settings?.headers; + this.customParameters = settings?.parameters; } async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise { @@ -19,18 +24,26 @@ export class LMStudioProvider implements AIProvider { messages.push({ role: 'user', content: prompt }); - const requestBody = { + const requestBody: Record = { model: this.model, messages, temperature: 0.7, max_tokens: 2000, + ...this.customParameters, + }; + + if (options?.jsonMode) { + requestBody.response_format = { type: 'json_object' }; + } + + const headers: Record = { + 'Content-Type': 'application/json', + ...this.customHeaders, }; const response = await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify(requestBody), }); @@ -63,7 +76,9 @@ export class LMStudioProvider implements AIProvider { async validate(): Promise { try { - const response = await fetch(`${this.baseUrl}/models`); + const response = await fetch(`${this.baseUrl}/models`, { + headers: this.customHeaders, + }); return response.ok; } catch { return false; diff --git a/backend/src/services/ai/ollama.ts b/backend/src/services/ai/ollama.ts index 58817db..ebf60cb 100644 --- a/backend/src/services/ai/ollama.ts +++ b/backend/src/services/ai/ollama.ts @@ -1,30 +1,39 @@ import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider'; +import { ProviderSettings } from './openai'; export class OllamaProvider implements AIProvider { provider = 'ollama' as const; private baseUrl: string; private model: string; + private customHeaders?: Record; + private customParameters?: Record; - constructor(config: AIProviderConfig) { + constructor(config: AIProviderConfig, settings?: ProviderSettings) { this.baseUrl = config.baseUrl || 'http://localhost:11434'; this.model = config.model || 'llama3.2'; + this.customHeaders = settings?.headers; + this.customParameters = settings?.parameters; } async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise { - const requestBody = { + const requestBody: Record = { model: this.model, stream: false, messages: [ ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), { role: 'user', content: prompt }, ], + ...this.customParameters, + }; + + const headers: Record = { + 'Content-Type': 'application/json', + ...this.customHeaders, }; const response = await fetch(`${this.baseUrl}/api/chat`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify(requestBody), }); @@ -57,7 +66,9 @@ export class OllamaProvider implements AIProvider { async validate(): Promise { try { - const response = await fetch(`${this.baseUrl}/api/tags`); + const response = await fetch(`${this.baseUrl}/api/tags`, { + headers: this.customHeaders, + }); return response.ok; } catch { return false; diff --git a/backend/src/services/ai/openai.ts b/backend/src/services/ai/openai.ts index 1c63a64..7db3827 100644 --- a/backend/src/services/ai/openai.ts +++ b/backend/src/services/ai/openai.ts @@ -1,15 +1,24 @@ import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider'; +export interface ProviderSettings { + headers?: Record; + parameters?: Record; +} + export class OpenAIProvider implements AIProvider { provider = 'openai' as const; private apiKey: string; private model: string; private baseUrl: string; + private customHeaders?: Record; + private customParameters?: Record; - constructor(config: AIProviderConfig) { + constructor(config: AIProviderConfig, settings?: ProviderSettings) { this.apiKey = config.apiKey; this.model = config.model || 'gpt-4'; this.baseUrl = config.baseUrl || 'https://api.openai.com/v1'; + this.customHeaders = settings?.headers; + this.customParameters = settings?.parameters; } async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise { @@ -26,18 +35,22 @@ export class OpenAIProvider implements AIProvider { messages, temperature: 0.7, max_tokens: 2000, + ...this.customParameters, }; if (options?.jsonMode) { requestBody.response_format = { type: 'json_object' }; } + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + ...this.customHeaders, + }; + const response = await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, + headers, body: JSON.stringify(requestBody), }); @@ -70,11 +83,11 @@ export class OpenAIProvider implements AIProvider { async validate(): Promise { try { - const response = await fetch(`${this.baseUrl}/models`, { - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - }, - }); + const headers: Record = { + 'Authorization': `Bearer ${this.apiKey}`, + ...this.customHeaders, + }; + const response = await fetch(`${this.baseUrl}/models`, { headers }); return response.ok; } catch { return false; diff --git a/backend/src/services/ai/provider.ts b/backend/src/services/ai/provider.ts index bbb2724..b7e2402 100644 --- a/backend/src/services/ai/provider.ts +++ b/backend/src/services/ai/provider.ts @@ -6,16 +6,33 @@ export interface AIProviderResult { } export interface AIProvider { - provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq'; + provider: string; generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise; validate?(): Promise; } export interface AIProviderConfig { - provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq'; - apiKey: string; + provider: string; + apiKey?: string; model?: string; baseUrl?: string; + providerSettings?: string; +} + +function parseProviderSettings(settingsJson?: string): { + headers?: Record; + parameters?: Record; +} { + if (!settingsJson) return {}; + try { + const parsed = JSON.parse(settingsJson); + return { + headers: parsed.headers, + parameters: parsed.parameters, + }; + } catch { + return {}; + } } import { OpenAIProvider } from './openai'; @@ -23,19 +40,24 @@ import { AnthropicProvider } from './anthropic'; import { OllamaProvider } from './ollama'; import { LMStudioProvider } from './lmstudio'; import { GroqProvider } from './groq'; +import { CustomProvider } from './custom'; export function createAIProvider(config: AIProviderConfig): AIProvider { + const settings = parseProviderSettings(config.providerSettings); + switch (config.provider) { case 'openai': - return new OpenAIProvider(config); + return new OpenAIProvider(config, settings); case 'anthropic': - return new AnthropicProvider(config); + return new AnthropicProvider(config, settings); case 'ollama': - return new OllamaProvider(config); + return new OllamaProvider(config, settings); case 'lmstudio': - return new LMStudioProvider(config); + return new LMStudioProvider(config, settings); case 'groq': - return new GroqProvider(config); + return new GroqProvider(config, settings); + case 'custom': + return new CustomProvider(config, settings); default: throw new Error(`Unknown AI provider: ${config.provider}`); }