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
19 changes: 15 additions & 4 deletions src/features/ai/AIHarness.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { loadConfig, saveConfig, maskApiKey } from '../../lib/llm/config';
import { loadConfig, saveConfig, maskApiKey, type LLMConfig } from '../../lib/llm/config';
import { saveDbHandles, getDbHandles } from '../../lib/db-persistence';
import { PROVIDER_MODELS } from '../../lib/llm';
import DatabaseSettings from '../../components/DatabaseSettings';
Expand All @@ -13,7 +13,18 @@ const WIZARD_SEEN_KEY = 'dks:ai-wizard-seen';
const PROVIDER_MODELS_MAP = new Map(Object.entries(PROVIDER_MODELS));

const AIHarness: React.FC = () => {
const [config, setConfig] = useState(() => loadConfig());
const [config, setConfig] = useState<LLMConfig>(() => ({
activeProvider: 'openrouter',
providers: {
openrouter: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '', defaultModel: 'google/gemini-2.0-flash-lite-preview-02-05:free' },
kilo: { baseURL: 'https://api.kilo.ai/api/gateway', apiKey: '', defaultModel: 'meta-llama/llama-3.1-8b-instruct' },
},
}));

// Load persisted config (with decrypted keys) on mount
useEffect(() => {
void loadConfig().then(setConfig);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

}, []);
const [showSettings, setShowSettings] = useState(false);
const [editApiKey, setEditApiKey] = useState('');
const [editProvider, setEditProvider] = useState(config.activeProvider);
Expand Down Expand Up @@ -87,7 +98,7 @@ const AIHarness: React.FC = () => {
entries.map(([key, val]) => [key, key === editProvider ? updatedProvider : val])
),
};
saveConfig(updated);
void saveConfig(updated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

setConfig(updated);
setShowSettings(false);
setEditApiKey('');
Expand All @@ -108,7 +119,7 @@ const AIHarness: React.FC = () => {
entries.map(([key, val]) => [key, key === provider ? updatedProvider : val])
),
};
saveConfig(updated);
void saveConfig(updated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

setConfig(updated);
localStorage.setItem(WIZARD_SEEN_KEY, 'true');
setShowWizard(false);
Expand Down
4 changes: 2 additions & 2 deletions src/features/ai/__tests__/AIHarness.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { screen } from '@testing-library/react';
import { renderWithDb } from '../../../test/test-utils';

vi.mock('../../../lib/llm/config', () => ({
loadConfig: vi.fn().mockReturnValue({
loadConfig: vi.fn().mockResolvedValue({
activeProvider: 'openrouter',
providers: {
openai: { apiKey: '', baseUrl: '', model: 'gpt-4o' },
openrouter: { apiKey: '', baseUrl: '', model: 'google/gemini-2.0-flash-lite-preview-02-05:free' },
},
}),
saveConfig: vi.fn(),
saveConfig: vi.fn().mockResolvedValue(undefined),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remove redundant `undefined` from function call


When an argument is omitted from a function call, it will default to undefined. It is therefore redundant to explicitly pass an undefined literal as the last argument.

createProvider: vi.fn().mockReturnValue({
chatStream: vi.fn().mockImplementation(() => {
function* gen() { yield { content: '', done: true }; }
Expand Down
2 changes: 1 addition & 1 deletion src/features/ai/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function useChat() {
}
}

const currentConfig = loadConfig();
const currentConfig = await loadConfig();
const provider = createProvider(currentConfig);

const promptMessages = [
Expand Down
22 changes: 13 additions & 9 deletions src/lib/llm/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ describe('loadConfig', () => {
localStorage.clear();
});

it('returns default config when localStorage is empty', () => {
const config = loadConfig();
it('returns default config when localStorage is empty', async () => {
const config = await loadConfig();
expect(config.activeProvider).toBe('openrouter');
expect(config.providers.openrouter.baseURL).toBe('https://openrouter.ai/api/v1');
expect(config.providers.kilo.baseURL).toBe('https://api.kilo.ai/api/gateway');
});

it('merges saved config with defaults (shallow)', () => {
it('merges saved config with defaults (shallow)', async () => {
const saved = {
activeProvider: 'kilo',
providers: {
Expand All @@ -56,16 +56,16 @@ describe('loadConfig', () => {
};
localStorage.setItem('dks:llm-config', JSON.stringify(saved));

const config = loadConfig();
const config = await loadConfig();
expect(config.activeProvider).toBe('kilo');
expect(config.providers.openrouter.apiKey).toBe('test-key');
expect(config.providers.openrouter.baseURL).toBe('https://custom.api/v1');
expect(config.providers.kilo.defaultModel).toBe('custom-kilo');
});

it('returns defaults on invalid JSON', () => {
it('returns defaults on invalid JSON', async () => {
localStorage.setItem('dks:llm-config', 'not-json');
const config = loadConfig();
const config = await loadConfig();
expect(config.activeProvider).toBe('openrouter');
});
});
Expand All @@ -75,19 +75,23 @@ describe('saveConfig', () => {
localStorage.clear();
});

it('persists config to localStorage', () => {
it('persists config to localStorage', async () => {
const config = {
activeProvider: 'kilo',
providers: {
openrouter: { baseURL: 'https://openrouter.ai/api/v1', apiKey: 'key1', defaultModel: 'm1' },
kilo: { baseURL: 'https://api.kilo.ai/api/gateway', apiKey: 'key2', defaultModel: 'm2' },
},
};
saveConfig(config);
await saveConfig(config);

const stored = JSON.parse(localStorage.getItem('dks:llm-config')!) as { activeProvider: string; providers: { openrouter: { apiKey: string } } };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Forbidden non-null assertion


Using non-null assertions cancels out the benefits of strict null-checking, and introduces the possibility of runtime errors. Avoid non-null assertions unless absolutely necessary. If you still need to use one, write a skipcq comment to explain why it is safe.

expect(stored.activeProvider).toBe('kilo');
expect(stored.providers.openrouter.apiKey).toBe('key1');
// API key should be encrypted (starts with enc:v1:)
expect(stored.providers.openrouter.apiKey).toMatch(/^enc:v1:/);
// But loading it back should decrypt
const loaded = await loadConfig();
expect(loaded.providers.openrouter.apiKey).toBe('key1');
});
});

Expand Down
45 changes: 40 additions & 5 deletions src/lib/llm/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LLMProvider, LLMProviderConfig } from './types';
import { OpenRouterProvider } from './openrouter';
import { KiloGatewayProvider } from './kilo';
import { encryptApiKey, decryptApiKey, isEncrypted } from './encryption';

const STORAGE_KEY = 'dks:llm-config';

Expand All @@ -25,20 +26,54 @@ const DEFAULT_CONFIG: LLMConfig = {
},
};

export function loadConfig(): LLMConfig {
export async function loadConfig(): Promise<LLMConfig> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

`loadConfig` has a cyclomatic complexity of 8 with "medium" risk


A function with high cyclomatic complexity can be hard to understand and
maintain. Cyclomatic complexity is a software metric that measures the number of
independent paths through a function. A higher cyclomatic complexity indicates
that the function has more decision points and is more complex.

try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return { ...DEFAULT_CONFIG, ...JSON.parse(stored) as Partial<LLMConfig> };
const parsed = JSON.parse(stored) as Partial<LLMConfig>;
const config = { ...DEFAULT_CONFIG, ...parsed };

// Decrypt provider API keys (migrates plaintext keys on the fly)
const migrated = { ...config, providers: { ...config.providers } };
let needsSave = false;
for (const [id, providerConfig] of Object.entries(migrated.providers)) {
if (providerConfig.apiKey && providerConfig.apiKey.length > 0) {
migrated.providers[id] = {
...providerConfig,
apiKey: await decryptApiKey(providerConfig.apiKey),
};
// Auto-migrate plaintext keys to encrypted
if (!isEncrypted(providerConfig.apiKey)) {
needsSave = true;
}
}
}

if (needsSave) {
// Re-save with encrypted keys (fire-and-forget)
void saveConfig(migrated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

}

return migrated;
}
} catch (e) {
console.warn('Failed to parse stored LLM config, falling back to defaults', e);
}
return { ...DEFAULT_CONFIG };
}
Comment on lines +29 to 63
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.


export function saveConfig(config: LLMConfig): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
export async function saveConfig(config: LLMConfig): Promise<void> {
// Encrypt all provider API keys before persisting
const encrypted = { ...config, providers: { ...config.providers } };
for (const [id, providerConfig] of Object.entries(encrypted.providers)) {
if (providerConfig.apiKey && providerConfig.apiKey.length > 0 && !isEncrypted(providerConfig.apiKey)) {
encrypted.providers[id] = {
...providerConfig,
apiKey: await encryptApiKey(providerConfig.apiKey),
};
}
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted));
}
Comment on lines +65 to 77
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.


export function createProvider(config: LLMConfig): LLMProvider {
Expand Down Expand Up @@ -69,7 +104,7 @@ export function getProvider(id: string, config?: Partial<LLMProviderConfig>): LL
}

export function maskApiKey(key: string): string {
if (!key || key.length < 8) return key ? `...${key.slice(-4)}` : '';
if (!key) return '';
return `...${key.slice(-4)}`;
}
Comment on lines 106 to 109
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.


Expand Down
89 changes: 89 additions & 0 deletions src/lib/llm/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const ENCRYPTION_KEY_STORAGE = 'dks:llm-encryption-key';

Check warning on line 1 in src/lib/llm/encryption.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/lib/llm/encryption.ts#L1

Hardcoded passwords are a security risk.
const ENCRYPTED_PREFIX = 'enc:v1:';

/**
* Get or create the AES-GCM encryption key.
* The key is stored in localStorage as a JWK for persistence across sessions.
*/
async function getKey(): Promise<CryptoKey> {
const stored = localStorage.getItem(ENCRYPTION_KEY_STORAGE);
if (stored) {
try {
return await crypto.subtle.importKey(
'jwk',
JSON.parse(stored) as JsonWebKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt'],
);
} catch {
// Key is corrupted, generate a new one
localStorage.removeItem(ENCRYPTION_KEY_STORAGE);
}
}

const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt'],
);
const exported = await crypto.subtle.exportKey('jwk', key);
localStorage.setItem(ENCRYPTION_KEY_STORAGE, JSON.stringify(exported));
return key;
}
Comment on lines +8 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.


/**
* Encrypt a plaintext string using AES-GCM.
* Returns a prefixed base64 string: `enc:v1:<iv+ciphertext>`
*/
export async function encryptApiKey(plaintext: string): Promise<string> {
const key = await getKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded,
);

// Combine IV + ciphertext and base64 encode
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encrypted), iv.length);

return ENCRYPTED_PREFIX + btoa(String.fromCharCode(...combined));
}
Comment on lines +39 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.


/**
* Decrypt an encrypted string back to plaintext.
* Supports both encrypted (`enc:v1:...`) and legacy plaintext values.
* If the value is not encrypted, it returns the raw value (migration path).
*/
export async function decryptApiKey(encrypted: string): Promise<string> {
if (!encrypted.startsWith(ENCRYPTED_PREFIX)) {
// Legacy plaintext key — return as-is for migration
return encrypted;
}

const key = await getKey();
const base64 = encrypted.slice(ENCRYPTED_PREFIX.length);
const combined = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));

const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);

const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext,
);

return new TextDecoder().decode(decrypted);
}
Comment on lines +62 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.


/**
* Check if a value is already encrypted.
*/
export function isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTED_PREFIX);
}
Comment on lines +87 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Loading