Skip to content
Merged
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
106 changes: 91 additions & 15 deletions electron-app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ function saveSetting(key, value) {

// ── API-key storage (safeStorage for Windows Credential Store) ────────────────

const API_KEY_FILE = 'apikey.enc';
const API_KEY_FILE = 'apikey.enc'; // legacy single-key file (Anthropic)
const PROVIDER_KEYS_FILE = 'apikeys.enc'; // multi-provider keys (JSON blob)

function storeApiKey(keyVal) {
if (safeStorage.isEncryptionAvailable()) {
Expand All @@ -163,17 +164,77 @@ function loadApiKey() {
return getSettings()._apiKeyPlain || '';
}

/**
* Load all per-provider API keys.
* Returns { anthropic, openai, google, nvidia } — empty string means not set.
* Migrates the legacy single-key file and nvidiaApiKey setting transparently.
*/
function loadProviderKeys() {
const keys = { anthropic: '', openai: '', google: '', nvidia: '' };
try {
const filePath = path.join(getUserData(), PROVIDER_KEYS_FILE);
if (fs.existsSync(filePath)) {
const enc = fs.readFileSync(filePath);
const json = safeStorage.isEncryptionAvailable()
? safeStorage.decryptString(enc)
: fs.readFileSync(filePath, 'utf8');
Object.assign(keys, JSON.parse(json));
}
} catch { /* ignore */ }
// Backward compat: migrate legacy apikey.enc → anthropic slot
if (!keys.anthropic) {
const legacy = loadApiKey();
if (legacy) keys.anthropic = legacy;
}
// Backward compat: nvidiaApiKey in settings → nvidia slot
if (!keys.nvidia) {
const s = getSettings();
if (s.nvidiaApiKey) keys.nvidia = s.nvidiaApiKey;
}
return keys;
}

/**
* Persist a per-provider API key.
* @param {string} provider - 'anthropic' | 'openai' | 'google' | 'nvidia'
* @param {string} key - key value; empty string clears the entry
*/
function storeProviderKey(provider, key) {
const keys = loadProviderKeys();
if (key) {
keys[provider] = key;
} else {
keys[provider] = '';
}
const json = JSON.stringify(keys);
const filePath = path.join(getUserData(), PROVIDER_KEYS_FILE);
if (safeStorage.isEncryptionAvailable()) {
fs.writeFileSync(filePath, safeStorage.encryptString(json));
} else {
fs.writeFileSync(filePath, json, 'utf8');
}
// Keep nvidiaApiKey in settings in sync for backward compat
if (provider === 'nvidia') {
saveSetting('nvidiaApiKey', key);
}
}

/**
* Return which providers have a key configured (from storage or environment).
*/
function getProviderKeyStatus() {
const keys = loadProviderKeys();
return {
anthropic: !!(keys.anthropic || process.env.ANTHROPIC_API_KEY),
openai: !!(keys.openai || process.env.OPENAI_API_KEY),
google: !!(keys.google || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY),
nvidia: !!(keys.nvidia || process.env.NVIDIA_API_KEY),
};
}

function hasApiKey() {
const storedKey = loadApiKey();
if (storedKey) return true;
return !!(
process.env.ANTHROPIC_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.GOOGLE_API_KEY ||
process.env.GEMINI_API_KEY ||
process.env.NVIDIA_API_KEY ||
getSettings().nvidiaApiKey
);
const status = getProviderKeyStatus();
return status.anthropic || status.openai || status.google || status.nvidia;
}

// ── In-Process Agent Bridge ───────────────────────────────────────────────────
Expand Down Expand Up @@ -632,9 +693,11 @@ function getBridge() {

function applyEnvFromSettings() {
const s = getSettings();
const key = loadApiKey() || process.env.ANTHROPIC_API_KEY || '';
if (key) process.env.ANTHROPIC_API_KEY = key;
if (s.nvidiaApiKey) process.env.NVIDIA_API_KEY = s.nvidiaApiKey;
const keys = loadProviderKeys();
if (keys.anthropic) process.env.ANTHROPIC_API_KEY = keys.anthropic;
if (keys.openai) process.env.OPENAI_API_KEY = keys.openai;
if (keys.google) { process.env.GOOGLE_API_KEY = keys.google; process.env.GEMINI_API_KEY = keys.google; }
if (keys.nvidia) process.env.NVIDIA_API_KEY = keys.nvidia;
process.env.ANTHROPIC_MODEL = s.model || 'claude-sonnet-4-6';
process.env.CLAUDE_CODE_PERMISSION_MODE = s.permissionMode || 'default';
process.env.CLAUDE_CODE_MAX_TURNS = String(s.maxTurns || 20);
Expand Down Expand Up @@ -783,7 +846,7 @@ async function handleSetApiKey() {
storeApiKey(result.trim());
applyEnvFromSettings();
if (agentBridge) { captureAgentMessages(); agentBridge.reinit(); agentBridge = null; }
mainWindow && mainWindow.webContents.send('main-message', { type: 'apiKeySet' });
mainWindow && mainWindow.webContents.send('main-message', { type: 'apiKeySet', providerKeyStatus: getProviderKeyStatus() });
dialog.showMessageBoxSync(mainWindow, {
type: 'info',
title: 'API Key Saved',
Expand Down Expand Up @@ -891,6 +954,7 @@ ipcMain.on('renderer-message', async (event, msg) => {
multiAgentStrategy: s.multiAgentStrategy || 'parallel',
systemPromptPreset: s.systemPromptPreset || 'expert-engineer',
mcpServers: (s.mcpServers && typeof s.mcpServers === 'object') ? s.mcpServers : {},
providerKeyStatus: getProviderKeyStatus(),
});

// Report which shell the integrated terminal will use
Expand Down Expand Up @@ -1444,6 +1508,18 @@ ipcMain.on('renderer-message', async (event, msg) => {
break;
}

// ── Save a per-provider API key ───────────────────────────────────────
case 'saveProviderKey': {
const validProviders = ['anthropic', 'openai', 'google', 'nvidia'];
if (msg.provider && validProviders.includes(msg.provider)) {
storeProviderKey(msg.provider, (msg.key || '').trim());
applyEnvFromSettings();
if (agentBridge) { captureAgentMessages(); agentBridge.reinit(); agentBridge = null; }
send({ type: 'apiKeySet', providerKeyStatus: getProviderKeyStatus() });
}
break;
}

// ── Save a single setting ─────────────────────────────────────────────
case 'saveSettings': {
const allowed = ['model','permissionMode','maxTurns','showToolOutput','nvidiaApiKey','workspacePath','defaultShell','multiAgentEnabled','multiAgentStrategy','providers','systemPromptPreset'];
Expand Down
49 changes: 49 additions & 0 deletions electron-app/renderer/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@
const settingsOpenFolderBtn = document.getElementById('settings-open-folder-btn');
const settingsGhLink = document.getElementById('settings-gh-link');

// ── Per-provider API key UI ───────────────────────────────────────────────
const apiKeyProviderSelect = document.getElementById('api-key-provider-select');
const apiKeyInput = document.getElementById('api-key-input');
const apiKeySaveBtn = document.getElementById('api-key-save-btn');
const apiKeysListEl = document.getElementById('api-keys-list');

// ── File explorer panel refs ─────────────────────────────────────────────
const explorerBtn = document.getElementById('explorer-btn'); // may be null
const explorerPanel = document.getElementById('explorer-panel'); // may be null (removed as overlay)
Expand Down Expand Up @@ -342,6 +348,7 @@
'claude-haiku-4-5': 200000,
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gemini-3-flash-preview': 1000000,
'gemini-2.0-flash': 1000000,
'moonshotai/kimi-k2.5': 128000,
'moonshotai/kimi-k2.6': 128000,
Expand Down Expand Up @@ -2335,6 +2342,7 @@
settingsKeyStatus.textContent = '✓ API key saved';
settingsKeyStatus.style.color = 'var(--success)';
}
if (apiKeysListEl && msg.providerKeyStatus) renderApiKeysList(msg.providerKeyStatus);
break;

case 'gitContext':
Expand Down Expand Up @@ -4122,6 +4130,7 @@
if (settingPersona) settingPersona.value = msg.systemPromptPreset || 'expert-engineer';
if (personaQuickSelect) personaQuickSelect.value = msg.systemPromptPreset || 'auto';
if (settingNvidiaKey) settingNvidiaKey.placeholder = msg.hasNvidiaKey ? '••••••• (set — enter to change)' : 'nvapi-… (leave blank to clear)';
if (apiKeysListEl && msg.providerKeyStatus) renderApiKeysList(msg.providerKeyStatus);
multiAgentEnabled = !!msg.multiAgentEnabled;
multiAgentStrategy = msg.multiAgentStrategy || 'parallel';
if (settingMultiAgentEnabled) settingMultiAgentEnabled.checked = multiAgentEnabled;
Expand Down Expand Up @@ -4430,6 +4439,46 @@
});
}

/**
* Render the API Keys list in the settings panel.
* @param {{ anthropic: boolean, openai: boolean, google: boolean, nvidia: boolean }} status
*/
function renderApiKeysList(status) {
if (!apiKeysListEl) return;
const labels = { anthropic: 'Anthropic', openai: 'OpenAI', google: 'Google (Gemini)', nvidia: 'NVIDIA NIM' };
const entries = Object.entries(labels).filter(([k]) => status[k]);
if (entries.length === 0) {
apiKeysListEl.innerHTML = '<div class="settings-note" style="opacity:0.6">No API keys saved yet.</div>';
return;
}
apiKeysListEl.innerHTML = entries.map(([provider, label]) => `
<div class="settings-row" style="padding:4px 0;gap:8px;align-items:center">
<span style="font-size:12px;flex:1">✓ <strong>${label}</strong> — key saved</span>
<button class="settings-action-btn api-key-clear-btn" data-provider="${provider}"
style="font-size:11px;padding:3px 10px">Clear</button>
</div>`).join('');
apiKeysListEl.querySelectorAll('.api-key-clear-btn').forEach(btn => {
btn.addEventListener('click', () => {
vscode.postMessage({ type: 'saveProviderKey', provider: btn.dataset.provider, key: '' });
});
});
}

if (apiKeySaveBtn) {
// doSave is only called from the Save button / Enter key — Clear buttons post directly
const doSave = () => {
const provider = apiKeyProviderSelect ? apiKeyProviderSelect.value : 'anthropic';
const key = apiKeyInput ? apiKeyInput.value.trim() : '';
if (!key) return; // prevent saving empty key from the input field
vscode.postMessage({ type: 'saveProviderKey', provider, key });
if (apiKeyInput) { apiKeyInput.value = ''; apiKeyInput.placeholder = 'Enter API key…'; }
};
apiKeySaveBtn.addEventListener('click', doSave);
if (apiKeyInput) {
apiKeyInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSave(); });
}
}

if (settingsOpenFolderBtn) {
settingsOpenFolderBtn.addEventListener('click', () => {
vscode.postMessage({ type: 'openSettingsFolder' });
Expand Down
22 changes: 12 additions & 10 deletions electron-app/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<option value="gpt-4o-mini">GPT-4o-mini</option>
</optgroup>
<optgroup label="Google">
<option value="gemini-3-flash-preview">Gemini 3 Flash Preview</option>
<option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
</optgroup>
<optgroup label="NVIDIA NIM">
Expand Down Expand Up @@ -735,17 +736,18 @@ <h2>freeCode</h2>

<div class="settings-section">
<div class="settings-section-label">API Keys</div>
<div class="settings-row">
<label class="settings-label">Anthropic / OpenAI / Google key</label>
<button class="settings-action-btn" id="settings-set-key-btn">🔑 Set API Key…</button>
</div>
<div class="settings-row">
<label class="settings-label" for="setting-nvidia-key">NVIDIA NIM API Key</label>
<div class="settings-path-row">
<input type="password" id="setting-nvidia-key" class="settings-input" placeholder="nvapi-… (leave blank to clear)">
<button class="settings-action-btn" id="settings-save-nvidia-btn">Save</button>
</div>
<div class="settings-row" style="gap:8px;flex-wrap:nowrap;align-items:center">
<select id="api-key-provider-select" class="settings-input" style="max-width:160px;flex-shrink:0">
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="google">Google (Gemini)</option>
<option value="nvidia">NVIDIA NIM</option>
</select>
<input type="password" id="api-key-input" class="settings-input"
placeholder="Enter API key…" autocomplete="off" spellcheck="false" style="flex:1;min-width:0">
<button class="settings-action-btn" id="api-key-save-btn" style="flex-shrink:0">Save</button>
</div>
<div id="api-keys-list" style="margin-top:8px"></div>
<div class="settings-note" id="settings-key-status"></div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion v2/src/core/providers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const PROVIDERS = {
authHeader(key) {
return { 'Content-Type': 'application/json' };
},
models: ['gemini-2.0-flash', 'gemini-2.0-pro', 'gemini-1.5-flash'],
models: ['gemini-3-flash-preview', 'gemini-2.0-flash', 'gemini-2.0-pro', 'gemini-1.5-flash'],
transformRequest(body) {
const contents = [];
for (const msg of body.messages || []) {
Expand Down
3 changes: 2 additions & 1 deletion v2/src/core/router.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const COST_TABLE = {
'claude-haiku-4-5': { input: 0.25, output: 1.25 },
'gpt-4o': { input: 5, output: 15 },
'gpt-4o-mini': { input: 0.15, output: 0.6 },
'gemini-3-flash-preview': { input: 0, output: 0 },
'gemini-1.5-flash': { input: 0.075, output: 0.3 },
};

Expand Down Expand Up @@ -134,7 +135,7 @@ export class Router {
const defaults = {
anthropic: 'claude-sonnet-4-5',
openai: 'gpt-4o-mini',
google: 'gemini-1.5-flash',
google: 'gemini-3-flash-preview',
};
return defaults[provider] || 'claude-sonnet-4-5';
}
Expand Down
1 change: 1 addition & 0 deletions vscode-extension/media/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
'claude-haiku-4-5': 200000,
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gemini-3-flash-preview': 1000000,
'gemini-2.0-flash': 1000000,
'moonshotai/kimi-k2.5': 128000,
'deepseek-ai/deepseek-r1': 64000,
Expand Down
Loading