Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 49 additions & 22 deletions src/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AbortController>();

// 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

/**
Expand Down Expand Up @@ -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) } };
}
Expand All @@ -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[]) => {
Expand All @@ -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();
Expand Down Expand Up @@ -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) => {
Expand All @@ -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, [
Expand Down
1 change: 1 addition & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
84 changes: 84 additions & 0 deletions src/renderer/components/Settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
59 changes: 44 additions & 15 deletions src/renderer/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DatabaseInfo | null>(null);
Expand All @@ -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) {
Expand All @@ -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');
Expand Down Expand Up @@ -220,21 +229,41 @@ export function Settings({ onClose }: SettingsProps) {
{activeTab === 'api' && (
<section className="settings-section">
<h3>OpenRouter API</h3>
<p className="section-description">
Configure your OpenRouter API key to access AI models. Get your API key from{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
window.api.shell.openExternal('https://openrouter.ai/keys');
}}
>
openrouter.ai/keys
</a>
</p>

{isFreeMode ? (
<div className="free-mode-banner">
<div className="free-mode-status">
<span className="status-badge free">Free Mode Active</span>
<p>You're using free AI models without an API key.</p>
</div>
<div className="free-mode-info">
<p>
<strong>Want access to more models?</strong> Add your own OpenRouter API key to unlock
hundreds of additional models including GPT-4, Claude, and more.
</p>
<p className="get-key-link">
Get your API key from{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
window.api.shell.openExternal('https://openrouter.ai/keys');
}}
>
openrouter.ai/keys
</a>
</p>
</div>
</div>
) : (
<div className="api-key-active-banner">
<span className="status-badge active">API Key Active</span>
<p>You have access to all available models.</p>
</div>
)}

<div className="api-key-section">
<label htmlFor="api-key">API Key</label>
<label htmlFor="api-key">API Key {isFreeMode && <span style={{ fontWeight: 'normal', color: 'var(--color-text-muted)' }}>(optional)</span>}</label>
<div className="api-key-input-wrapper">
<input
id="api-key"
Expand Down Expand Up @@ -300,7 +329,7 @@ export function Settings({ onClose }: SettingsProps) {
<h4>About OpenRouter</h4>
<p>
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.'}
</p>
</div>
</section>
Expand Down
Loading