Skip to content
10 changes: 10 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
# Get your API key from: https://makersuite.google.com/app/apikey
GEMINI_API_KEY=your_gemini_api_key_here

# LLM Provider Configuration
# Choose which AI backend to use: gemini | openrouter
# Changing this requires an app restart (the provider is selected at startup).
LLM_PROVIDER=gemini
# OpenRouter API key — required only when LLM_PROVIDER=openrouter
# Get your key from: https://openrouter.ai/keys
OPENROUTER_API_KEY=your_openrouter_key_here
# Vision-capable model to use with OpenRouter (can also be set in Settings UI)
OPENROUTER_MODEL=anthropic/claude-sonnet-4

# Speech Recognition Configuration
# Choose one provider: azure or whisper
SPEECH_PROVIDER=whisper
Expand Down
42 changes: 41 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ process.on("unhandledRejection", (reason) => {
// Screen capture (image-based)
const captureService = require("./src/services/capture.service");
const speechService = require("./src/services/speech.service");
const llmService = require("./src/services/llm.service");
// llm.factory selects openrouter.service or llm.service based on LLM_PROVIDER env var.
const llmService = require("./src/services/llm.factory");

// Managers
const windowManager = require("./src/managers/window.manager");
Expand Down Expand Up @@ -1608,6 +1609,11 @@ class ApplicationController {
whisperSegmentMs: process.env.WHISPER_SEGMENT_MS || "4000",
geminiKey: process.env.GEMINI_API_KEY || "",

// OpenRouter provider fields
llmProvider: process.env.LLM_PROVIDER || "gemini",
openrouterKey: process.env.OPENROUTER_API_KEY || "",
openrouterModel: process.env.OPENROUTER_MODEL || "anthropic/claude-sonnet-4",

azureConfigured: !!process.env.AZURE_SPEECH_KEY && !!process.env.AZURE_SPEECH_REGION,
speechAvailable: this.speechAvailable
};
Expand Down Expand Up @@ -1679,6 +1685,17 @@ class ApplicationController {
envUpdates.GEMINI_API_KEY = settings.geminiKey;
}

// OpenRouter provider settings
if (settings.llmProvider === "openrouter" || settings.llmProvider === "gemini") {
envUpdates.LLM_PROVIDER = settings.llmProvider;
}
if (settings.openrouterKey !== undefined) {
envUpdates.OPENROUTER_API_KEY = settings.openrouterKey;
}
if (settings.openrouterModel !== undefined && settings.openrouterModel.trim()) {
envUpdates.OPENROUTER_MODEL = settings.openrouterModel.trim();
}
Comment thread
ShlokNaidu marked this conversation as resolved.
Outdated

// Capture the previous whisper command BEFORE persisting — persistEnvUpdates
// mutates process.env in place, so comparing afterwards would always read
// equal and skip the speech re-init below (the exact stale-mic-after-install
Expand All @@ -1703,6 +1720,29 @@ class ApplicationController {
}
}

// If the OpenRouter key was saved and the current runtime service is
// OpenRouter, reinitialize its client so the key is picked up immediately.
if (settings.openrouterKey !== undefined && envUpdates.OPENROUTER_API_KEY !== undefined) {
try {
if (typeof llmService.updateApiKey === 'function' &&
llmService.constructor && llmService.constructor.name === 'OpenRouterService') {
llmService.updateApiKey(settings.openrouterKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply OpenRouter model changes at runtime

When OpenRouter is already active and the user changes only the model, saveSettings persists OPENROUTER_MODEL but this reinitialization block is guarded solely by openrouterKey. OpenRouterService sends requests using this.model, initialized once at startup, so subsequent requests keep using the old model until restart despite the Settings UI note saying model updates apply immediately when OpenRouter is active.

Useful? React with 👍 / 👎.

logger.info("OpenRouter service reinitialized after key update");
}
} catch (e) {
logger.warn("Failed to reinitialize OpenRouter service after key update", { error: e.message });
}
}
Comment thread
ShlokNaidu marked this conversation as resolved.
Outdated

// Notify UI about provider change (restart still required for factory to switch)
if (settings.llmProvider !== undefined) {
windowManager.broadcastToAllWindows("llm-provider-changed", {
provider: settings.llmProvider,
requiresRestart: true
});
logger.info("LLM provider setting updated; restart required for factory to reload", { provider: settings.llmProvider });
}

// Reinitialize speech service when provider OR whisper command
// changes. Without the second check, the install flow (which
// writes a new whisperCommand after install but keeps the same
Expand Down
43 changes: 39 additions & 4 deletions settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -459,15 +459,50 @@
<div class="settings-section">
<div class="settings-section-title">
<i class="fas fa-robot"></i>
Gemini Settings
AI Provider
</div>
<div class="settings-section-content">
<div class="settings-item">
<div>
<div class="settings-item-label">Google API Key</div>
<div class="settings-item-description">Your Google API key for Gemini models</div>
<div class="settings-item-label">LLM Provider</div>
<div class="settings-item-description">Choose your AI backend (restart required to switch)</div>
</div>
<select class="input-field" id="llmProvider">
<option value="gemini">Google Gemini</option>
<option value="openrouter">OpenRouter</option>
</select>
</div>

<!-- Gemini fields (shown when gemini is selected) -->
<div id="geminiFields">
<div class="settings-item">
<div>
<div class="settings-item-label">Google API Key</div>
<div class="settings-item-description">Your Google API key for Gemini models</div>
</div>
<input type="password" class="input-field" id="geminiKey" placeholder="Enter your Google API key">
</div>
</div>

<!-- OpenRouter fields (shown when openrouter is selected) -->
<div id="openrouterFields">
<div class="settings-item">
<div>
<div class="settings-item-label">OpenRouter API Key</div>
<div class="settings-item-description">Get your key at <a href="https://openrouter.ai/keys" target="_blank" style="color:rgba(255,255,255,0.7)">openrouter.ai/keys</a></div>
</div>
<input type="password" class="input-field" id="openrouterKey" placeholder="Enter your OpenRouter key">
</div>
<div class="settings-item">
<div>
<div class="settings-item-label">OpenRouter Model</div>
<div class="settings-item-description">Vision-capable model ID (e.g. anthropic/claude-sonnet-4)</div>
</div>
<input type="text" class="input-field" id="openrouterModel" placeholder="anthropic/claude-sonnet-4">
</div>
<div class="settings-note" style="margin-top:8px;">
Provider change takes effect after <strong>restarting the app</strong>. API key and model updates apply immediately if OpenRouter is already active.
</div>
<input type="password" class="input-field" id="geminiKey" placeholder="Enter your Google API key">
</div>
</div>
</div>
Expand Down
18 changes: 18 additions & 0 deletions src/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ class ConfigManager {
},

llm: {
// Active provider: 'gemini' | 'openrouter'. Reads LLM_PROVIDER env var
// at startup; a factory module (llm.factory.js) selects the right service.
// Changing this at runtime requires an app restart (Node module cache).
provider: process.env.LLM_PROVIDER || 'gemini',

gemini: {
model: 'gemini-3.1-flash-lite',
fallbackModels: ['gemini-2.5-flash-lite', 'gemini-3.5-flash'],
Expand All @@ -53,6 +58,19 @@ class ConfigManager {
maxOutputTokens: 4096,
thinkingConfig: { thinkingBudget: 0 }
}
},

openrouter: {
// Vision-capable model that also handles text well.
// Override with OPENROUTER_MODEL env var or via the settings UI.
model: process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4',
maxRetries: 3,
timeout: 60000,
fallbackEnabled: true,
generation: {
temperature: 0.7,
max_tokens: 4096
}
}
},

Expand Down
14 changes: 14 additions & 0 deletions src/core/first-run.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ class FirstRunManager {
if (!fs.existsSync(this.sentinelPath)) return true;
if (!fs.existsSync(this.envPath)) return true;
const content = this._readEnv();
const provider = (content.LLM_PROVIDER || 'gemini').trim();
if (provider === 'openrouter') {
// When OpenRouter is selected, check its key instead of Gemini's
const orKey = (content.OPENROUTER_API_KEY || '').trim();
return !orKey || orKey === 'your_openrouter_key_here';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep OpenRouter onboarding configurable

When .env contains LLM_PROVIDER=openrouter and the OpenRouter key is missing, this branch now forces first-run onboarding, but the onboarding wizard still only shows a Gemini key screen and only persists { geminiKey } from the API-key step. A user can complete that wizard after Gemini is configured, yet this condition remains true on the next launch because OPENROUTER_API_KEY was never collected, causing repeat onboarding without a way to configure the selected provider.

Useful? React with 👍 / 👎.

}
const gemini = (content.GEMINI_API_KEY || '').trim();
return !gemini || gemini === 'your_gemini_api_key_here';
}
Expand Down Expand Up @@ -80,10 +86,13 @@ class FirstRunManager {
getStatus() {
const env = this._readEnv();
const gemini = (env.GEMINI_API_KEY || '').trim();
const openrouter = (env.OPENROUTER_API_KEY || '').trim();
return {
envExists: fs.existsSync(this.envPath),
sentinelExists: fs.existsSync(this.sentinelPath),
geminiConfigured: !!gemini && gemini !== 'your_gemini_api_key_here',
openrouterConfigured: !!openrouter && openrouter !== 'your_openrouter_key_here',
llmProvider: (env.LLM_PROVIDER || 'gemini').trim(),
azureConfigured: !!(env.AZURE_SPEECH_KEY || '').trim() && !!(env.AZURE_SPEECH_REGION || '').trim(),
whisperConfigured: !!(env.WHISPER_COMMAND || '').trim(),
needsOnboarding: this.needsOnboarding()
Expand Down Expand Up @@ -144,6 +153,11 @@ class FirstRunManager {
'',
'GEMINI_API_KEY=your_gemini_api_key_here',
'',
'# LLM Provider: gemini (default) | openrouter (restart required to switch)',
'# LLM_PROVIDER=gemini',
'# OPENROUTER_API_KEY=your_openrouter_key_here',
'# OPENROUTER_MODEL=anthropic/claude-sonnet-4',
'',
'# Speech provider: "whisper" (local) or "azure" (cloud).',
'# WHISPER_COMMAND is auto-set to the project-local venv when you',
'# install Whisper through the onboarding wizard, so no PATH change',
Expand Down
22 changes: 22 additions & 0 deletions src/services/llm.factory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* LLM Provider Factory
*
* Selects the active LLM service based on config.get('llm.provider').
* The value is read once at require() time from config, which itself reads
* the LLM_PROVIDER environment variable (set in .env before app start).
*
* Changing the provider via the settings UI updates .env and process.env,
* but takes effect only on the NEXT app restart because Node caches modules.
*
* Default: 'gemini' (backward-compatible).
*/
'use strict';

const config = require('../core/config');
const provider = config.get('llm.provider') || 'gemini';

if (provider === 'openrouter') {
module.exports = require('./openrouter.service');
} else {
module.exports = require('./llm.service');
}
Loading