diff --git a/src/ipc/handlers.ts b/src/ipc/handlers.ts index c250f28..6521c68 100644 --- a/src/ipc/handlers.ts +++ b/src/ipc/handlers.ts @@ -9,14 +9,15 @@ import { ipcMain, dialog, shell, app, BrowserWindow } from 'electron'; import { autoUpdater } from 'electron-updater'; import { getDatabase, closeDatabase, getCurrentDatabasePath } from '../database/connection'; import { getDatabaseInfo, migrateDatabase, getDefaultDatabasePath, saveDatabaseConfig } from '../database/config'; -import { fetchModels, streamCompletion, ChatMessage, OpenRouterModel } from '../services/openrouter'; +import { fetchModels, streamCompletion, isFreeModel, ChatMessage, OpenRouterModel } from '../services/openrouter'; // Store for active stream abort controllers const activeStreams = new Map(); -// Cache for models +// Cache for models (separate caches for free mode vs authenticated mode) let modelsCache: OpenRouterModel[] | null = null; let modelsCacheTime = 0; +let modelsCacheIsFreeMode = false; const MODELS_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes /** @@ -219,22 +220,24 @@ export function registerIPCHandlers(): void { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('openrouter_api_key') as | { value: string } | undefined; - const apiKey = row?.value; - - if (!apiKey) { - return { success: false, error: { code: 'NO_API_KEY', message: 'OpenRouter API key not configured' } }; - } - - // Check cache - if (modelsCache && Date.now() - modelsCacheTime < MODELS_CACHE_DURATION) { - return { success: true, data: modelsCache }; + const apiKey = row?.value || undefined; + const isFreeMode = !apiKey; + + // Check cache (invalidate if mode changed) + if ( + modelsCache && + Date.now() - modelsCacheTime < MODELS_CACHE_DURATION && + modelsCacheIsFreeMode === isFreeMode + ) { + return { success: true, data: modelsCache, isFreeMode }; } const models = await fetchModels(apiKey); modelsCache = models; modelsCacheTime = Date.now(); + modelsCacheIsFreeMode = isFreeMode; - return { success: true, data: models }; + return { success: true, data: models, isFreeMode }; } catch (error) { return { success: false, error: { code: 'FETCH_MODELS_ERROR', message: String(error) } }; } @@ -246,6 +249,25 @@ export function registerIPCHandlers(): void { return { success: true }; }); + ipcMain.handle('openrouter:getApiKeyStatus', async () => { + try { + const db = getDatabase(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('openrouter_api_key') as + | { value: string } + | undefined; + const hasApiKey = !!row?.value; + return { + success: true, + data: { + hasApiKey, + isFreeMode: !hasApiKey, + }, + }; + } catch (error) { + return { success: false, error: { code: 'GET_STATUS_ERROR', message: String(error) } }; + } + }); + ipcMain.handle( 'openrouter:startStream', async (event, streamId: string, model: string, messages: ChatMessage[]) => { @@ -254,10 +276,18 @@ export function registerIPCHandlers(): void { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('openrouter_api_key') as | { value: string } | undefined; - const apiKey = row?.value; - - if (!apiKey) { - return { success: false, error: { code: 'NO_API_KEY', message: 'OpenRouter API key not configured' } }; + const apiKey = row?.value || undefined; + const isFreeMode = !apiKey; + + // In free mode, only allow free models + if (isFreeMode && !model.endsWith(':free')) { + return { + success: false, + error: { + code: 'FREE_MODE_RESTRICTION', + message: 'This model requires an API key. Please add your OpenRouter API key in Settings to use paid models.', + }, + }; } const abortController = new AbortController(); @@ -335,7 +365,7 @@ export function registerIPCHandlers(): void { return { success: true }; }); - // Free model for generating conversation titles + // Free model for generating conversation titles (works without API key) const TITLE_GENERATION_MODEL = 'meta-llama/llama-3.2-3b-instruct:free'; ipcMain.handle('openrouter:generateTitle', async (_, userMessage: string) => { @@ -344,11 +374,8 @@ export function registerIPCHandlers(): void { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('openrouter_api_key') as | { value: string } | undefined; - const apiKey = row?.value; - - if (!apiKey) { - return { success: false, error: { code: 'NO_API_KEY', message: 'OpenRouter API key not configured' } }; - } + // API key is optional - title generation uses a free model + const apiKey = row?.value || undefined; const { getCompletion } = await import('../services/openrouter'); const result = await getCompletion(apiKey, TITLE_GENERATION_MODEL, [ diff --git a/src/preload.ts b/src/preload.ts index 6b80a69..0c3791b 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -77,6 +77,7 @@ interface StreamChunkData { const openrouterAPI = { getModels: () => ipcRenderer.invoke('openrouter:getModels'), clearModelsCache: () => ipcRenderer.invoke('openrouter:clearModelsCache'), + getApiKeyStatus: () => ipcRenderer.invoke('openrouter:getApiKeyStatus'), startStream: (streamId: string, model: string, messages: ChatMessage[]) => ipcRenderer.invoke('openrouter:startStream', streamId, model, messages), stopStream: (streamId: string) => ipcRenderer.invoke('openrouter:stopStream', streamId), diff --git a/src/renderer/components/Settings.css b/src/renderer/components/Settings.css index 2bd743f..204e58a 100644 --- a/src/renderer/components/Settings.css +++ b/src/renderer/components/Settings.css @@ -337,3 +337,87 @@ .section-description a:hover { text-decoration: underline; } + +/* Free Mode Banner */ +.free-mode-banner { + padding: var(--space-md); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(147, 51, 234, 0.1) 100%); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: var(--radius-lg); + margin-bottom: var(--space-lg); +} + +.free-mode-status { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-sm); +} + +.free-mode-status p { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text); +} + +.status-badge { + display: inline-block; + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.status-badge.free { + background: linear-gradient(135deg, #3b82f6 0%, #9333ea 100%); + color: white; +} + +.status-badge.active { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; +} + +.free-mode-info p { + font-size: var(--text-sm); + color: var(--color-text-secondary); + margin: 0 0 var(--space-xs) 0; + line-height: 1.5; +} + +.free-mode-info p:last-child { + margin-bottom: 0; +} + +.free-mode-info strong { + color: var(--color-text); +} + +.get-key-link a { + color: var(--color-primary); + text-decoration: none; +} + +.get-key-link a:hover { + text-decoration: underline; +} + +/* API Key Active Banner */ +.api-key-active-banner { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); + border-radius: var(--radius-lg); + margin-bottom: var(--space-lg); +} + +.api-key-active-banner p { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text); +} diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 0232164..c91e27c 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -24,6 +24,7 @@ export function Settings({ onClose }: SettingsProps) { const [apiKeyStatus, setApiKeyStatus] = useState<'idle' | 'saved' | 'error'>('idle'); const [testingApiKey, setTestingApiKey] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [isFreeMode, setIsFreeMode] = useState(true); // Database location state const [dbInfo, setDbInfo] = useState(null); @@ -42,6 +43,12 @@ export function Settings({ onClose }: SettingsProps) { setApiKey(apiKeyResult.data); } + // Check API key status (free mode vs own key) + const statusResult = await window.api.openrouter.getApiKeyStatus(); + if (statusResult.success && statusResult.data) { + setIsFreeMode(statusResult.data.isFreeMode); + } + // Load database info const dbInfoResult = await window.api.database.getInfo(); if (dbInfoResult.success && dbInfoResult.data) { @@ -66,9 +73,11 @@ export function Settings({ onClose }: SettingsProps) { const result = await window.api.settings.set('openrouter_api_key', apiKey); if (result.success) { setApiKeyStatus('saved'); + // Update free mode status + setIsFreeMode(!apiKey.trim()); // Clear models cache so they reload with new key await window.api.openrouter.clearModelsCache(); - toast.success('API key saved'); + toast.success(apiKey.trim() ? 'API key saved' : 'API key cleared - using free models'); setTimeout(() => setApiKeyStatus('idle'), 2000); } else { setApiKeyStatus('error'); @@ -220,21 +229,41 @@ export function Settings({ onClose }: SettingsProps) { {activeTab === 'api' && (

OpenRouter API

-

- Configure your OpenRouter API key to access AI models. Get your API key from{' '} - { - e.preventDefault(); - window.api.shell.openExternal('https://openrouter.ai/keys'); - }} - > - openrouter.ai/keys - -

+ + {isFreeMode ? ( +
+
+ Free Mode Active +

You're using free AI models without an API key.

+
+
+

+ Want access to more models? Add your own OpenRouter API key to unlock + hundreds of additional models including GPT-4, Claude, and more. +

+

+ Get your API key from{' '} + { + e.preventDefault(); + window.api.shell.openExternal('https://openrouter.ai/keys'); + }} + > + openrouter.ai/keys + +

+
+
+ ) : ( +
+ API Key Active +

You have access to all available models.

+
+ )}
- +
About OpenRouter

OpenRouter provides a unified API to access models from OpenAI, Anthropic, Google, Meta, and many - other providers. You only need one API key to compare all available models. + other providers. {isFreeMode ? 'Free models are available without an API key.' : 'You only need one API key to compare all available models.'}

diff --git a/src/services/openrouter.ts b/src/services/openrouter.ts index a24faa5..4ba0c2a 100644 --- a/src/services/openrouter.ts +++ b/src/services/openrouter.ts @@ -70,43 +70,73 @@ export interface CompletionResult { const OPENROUTER_API_BASE = 'https://openrouter.ai/api/v1'; +/** + * Check if a model is free (has :free suffix or zero pricing) + */ +export function isFreeModel(model: OpenRouterModel): boolean { + return ( + model.id.endsWith(':free') || + (parseFloat(model.pricing.prompt) === 0 && parseFloat(model.pricing.completion) === 0) + ); +} + +/** + * Build headers for OpenRouter API requests + * API key is optional - free models can be accessed without authentication + */ +function buildHeaders(apiKey?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://www.modelfaceoff.com', + 'X-Title': 'Model Faceoff', + }; + + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + return headers; +} + /** * Fetch all available models from OpenRouter + * If no API key is provided, only free models will be returned */ -export async function fetchModels(apiKey: string): Promise { - const response = await fetch(`${OPENROUTER_API_BASE}/models`, { - headers: { - Authorization: `Bearer ${apiKey}`, - 'HTTP-Referer': 'https://www.modelfaceoff.com', - 'X-Title': 'Model Faceoff', - }, - }); +export async function fetchModels(apiKey?: string): Promise { + const headers = buildHeaders(apiKey); + // Remove Content-Type for GET request + delete headers['Content-Type']; + + const response = await fetch(`${OPENROUTER_API_BASE}/models`, { headers }); if (!response.ok) { throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`); } const data = (await response.json()) as OpenRouterModelsResponse; - return data.data; + let models = data.data; + + // If no API key, filter to only free models + if (!apiKey) { + models = models.filter(isFreeModel); + } + + return models; } /** * Stream a chat completion from OpenRouter + * API key is optional for free models */ export async function* streamCompletion( - apiKey: string, + apiKey: string | undefined, model: string, messages: ChatMessage[], signal?: AbortSignal ): AsyncGenerator { const response = await fetch(`${OPENROUTER_API_BASE}/chat/completions`, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://www.modelfaceoff.com', - 'X-Title': 'Model Faceoff', - }, + headers: buildHeaders(apiKey), body: JSON.stringify({ model, messages, @@ -162,9 +192,10 @@ export async function* streamCompletion( /** * Get a non-streaming completion + * API key is optional for free models */ export async function getCompletion( - apiKey: string, + apiKey: string | undefined, model: string, messages: ChatMessage[] ): Promise { @@ -172,12 +203,7 @@ export async function getCompletion( const response = await fetch(`${OPENROUTER_API_BASE}/chat/completions`, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://www.modelfaceoff.com', - 'X-Title': 'Model Faceoff', - }, + headers: buildHeaders(apiKey), body: JSON.stringify({ model, messages, diff --git a/src/types/window.ts b/src/types/window.ts index 83cce88..3f5f606 100644 --- a/src/types/window.ts +++ b/src/types/window.ts @@ -132,12 +132,29 @@ export interface StreamChunkData { error?: string; } +/** + * API Key Status + */ +export interface ApiKeyStatus { + hasApiKey: boolean; + isFreeMode: boolean; +} + +/** + * Get Models Response (includes isFreeMode flag) + */ +export interface GetModelsResponse { + data?: OpenRouterModel[]; + isFreeMode?: boolean; +} + /** * OpenRouter API */ export interface OpenRouterAPI { - getModels: () => Promise>; + getModels: () => Promise & { isFreeMode?: boolean }>; clearModelsCache: () => Promise>; + getApiKeyStatus: () => Promise>; startStream: (streamId: string, model: string, messages: ChatMessage[]) => Promise>; stopStream: (streamId: string) => Promise>; generateTitle: (userMessage: string) => Promise>;