Phase 12: LLM provider config + client + CSP

- Bring-your-own-key assistant config in a dedicated persisted store
  (src/stores/assistantStore.ts); partialize drops the key from localStorage
  when 'remember' is off. Key lives in localStorage only, so it's auto-excluded
  from Dexie backups.
- Provider-agnostic LLM client (src/lib/llm) over fetch, no SDK: Anthropic
  Messages + OpenAI-compatible Chat Completions (covers OpenAI/OpenRouter/
  Ollama/LM Studio via custom base URL). Structured JSON output parsed +
  Zod-validated; timeouts; typed error kinds; key never leaked in errors.
- Settings 'Assistant (AI)' section with provider/model/baseUrl/key, remember
  + enabled toggles, and Test connection.
- CSP connect-src widened to any https + localhost for the BYO endpoint.

10 client unit tests; settings e2e asserts config persists and the key is
never present in an exported backup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:04:20 +02:00
parent 0930136c46
commit 7fd5fcd9af
9 changed files with 523 additions and 2 deletions
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { z } from 'zod';
import { complete } from './client';
import type { LlmConfig } from './types';
const base: LlmConfig = {
enabled: true, provider: 'anthropic', model: 'claude-opus-4-8',
baseUrl: 'https://api.anthropic.com', apiKey: 'secret-key-123', rememberKey: true,
};
function mockFetch(impl: (url: string, init: RequestInit) => Response | Promise<Response>) {
const fn = vi.fn(impl);
vi.stubGlobal('fetch', fn);
return fn;
}
function anthropicReply(text: string) {
return new Response(JSON.stringify({ content: [{ type: 'text', text }] }), { status: 200 });
}
function openaiReply(text: string) {
return new Response(JSON.stringify({ choices: [{ message: { content: text } }] }), { status: 200 });
}
afterEach(() => vi.unstubAllGlobals());
describe('complete — guards', () => {
it('short-circuits when disabled without calling fetch', async () => {
const fn = mockFetch(() => anthropicReply('hi'));
const r = await complete({ ...base, enabled: false }, { system: 's', user: 'u' });
expect(r).toEqual({ ok: false, error: 'disabled', message: expect.any(String) });
expect(fn).not.toHaveBeenCalled();
});
it('reports no-key without calling fetch', async () => {
const fn = mockFetch(() => anthropicReply('hi'));
const r = await complete({ ...base, apiKey: ' ' }, { system: 's', user: 'u' });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('no-key');
expect(fn).not.toHaveBeenCalled();
});
});
describe('complete — request shapes', () => {
it('builds the Anthropic body + headers', async () => {
const fn = mockFetch(() => anthropicReply('hello'));
const r = await complete(base, { system: 'sys', user: 'hi' });
expect(r).toEqual({ ok: true, text: 'hello' });
const [url, init] = fn.mock.calls[0]!;
expect(url).toBe('https://api.anthropic.com/v1/messages');
const headers = init.headers as Record<string, string>;
expect(headers['x-api-key']).toBe('secret-key-123');
expect(headers['anthropic-version']).toBe('2023-06-01');
expect(headers['anthropic-dangerous-direct-browser-access']).toBe('true');
const body = JSON.parse(init.body as string);
expect(body.model).toBe('claude-opus-4-8');
expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]);
});
it('builds the OpenAI body + headers, with json mode when a schema is given', async () => {
const fn = mockFetch(() => openaiReply('{"n":5}'));
const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' };
const r = await complete(cfg, { system: 'sys', user: 'hi', schema: z.object({ n: z.number() }) });
expect(r).toEqual({ ok: true, data: { n: 5 } });
const [url, init] = fn.mock.calls[0]!;
expect(url).toBe('https://api.openai.com/v1/chat/completions');
const headers = init.headers as Record<string, string>;
expect(headers.authorization).toBe('Bearer secret-key-123');
const body = JSON.parse(init.body as string);
expect(body.response_format).toEqual({ type: 'json_object' });
expect(body.messages[0].role).toBe('system');
});
});
describe('complete — structured output', () => {
const schema = z.object({ name: z.string() });
it('parses JSON wrapped in markdown fences', async () => {
mockFetch(() => anthropicReply('```json\n{"name":"Goblin"}\n```'));
const r = await complete(base, { system: 's', user: 'u', schema });
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
});
it('returns parse error on malformed JSON', async () => {
mockFetch(() => anthropicReply('not json at all'));
const r = await complete(base, { system: 's', user: 'u', schema });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('parse');
});
it('returns parse error when JSON does not match the schema', async () => {
mockFetch(() => anthropicReply('{"wrong":1}'));
const r = await complete(base, { system: 's', user: 'u', schema });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('parse');
});
});
describe('complete — errors never leak the key', () => {
it('maps non-2xx to http', async () => {
mockFetch(() => new Response('rate limited', { status: 429 }));
const r = await complete(base, { system: 's', user: 'u' });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('http');
expect(r.message).not.toContain('secret-key-123');
}
});
it('maps fetch rejection to network', async () => {
mockFetch(() => { throw new TypeError('Failed to fetch'); });
const r = await complete(base, { system: 's', user: 'u' });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('network');
expect(r.message).not.toContain('secret-key-123');
}
});
it('maps abort/timeout', async () => {
mockFetch((_url, init) => new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
}));
const r = await complete(base, { system: 's', user: 'u', timeoutMs: 10 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('timeout');
});
});
+162
View File
@@ -0,0 +1,162 @@
import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types';
const DEFAULT_TIMEOUT = 30_000;
const DEFAULT_MAX_TOKENS = 1024;
function err<T>(error: LlmErrorKind, message: string): LlmResult<T> {
return { ok: false, error, message };
}
function trimSlash(url: string): string {
return url.replace(/\/+$/, '');
}
function jsonInstruction(): string {
return '\n\nRespond with ONLY a single valid JSON object — no prose, no markdown code fences — matching the requested shape exactly.';
}
/** Best-effort extraction of a JSON object from a model response. */
function tryParseJson(text: string): unknown {
const trimmed = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
try {
return JSON.parse(candidate);
} catch {
return undefined;
}
}
async function safeText(res: Response): Promise<string> {
try {
return (await res.text()).slice(0, 300);
} catch {
return '';
}
}
interface Request {
url: string;
headers: Record<string, string>;
body: unknown;
}
function buildAnthropic(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean): Request {
return {
url: `${trimSlash(cfg.baseUrl)}/v1/messages`,
headers: {
'content-type': 'application/json',
'x-api-key': cfg.apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: {
model: cfg.model,
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
system: wantJson ? opts.system + jsonInstruction() : opts.system,
messages: [{ role: 'user', content: opts.user }],
},
};
}
function buildOpenai(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean): Request {
return {
url: `${trimSlash(cfg.baseUrl)}/chat/completions`,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${cfg.apiKey}`,
},
body: {
model: cfg.model,
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
messages: [
{ role: 'system', content: wantJson ? opts.system + jsonInstruction() : opts.system },
{ role: 'user', content: opts.user },
],
...(wantJson ? { response_format: { type: 'json_object' } } : {}),
},
};
}
function extractText(provider: LlmConfig['provider'], json: unknown): string {
const j = json as Record<string, unknown>;
if (provider === 'anthropic') {
const content = j.content as Array<{ type?: string; text?: string }> | undefined;
return content?.find((b) => b.type === 'text')?.text ?? '';
}
const choices = j.choices as Array<{ message?: { content?: string } }> | undefined;
return choices?.[0]?.message?.content ?? '';
}
/**
* Provider-agnostic completion. Supports Anthropic Messages and OpenAI-compatible
* Chat Completions via fetch (no SDK). With `opts.schema`, requests JSON and
* validates it — returning `{ok:true,data}`; otherwise returns `{ok:true,text}`.
* Never throws and never includes the API key in an error message.
*/
export async function complete<T>(cfg: LlmConfig, opts: CompleteOptions<T>): Promise<LlmResult<T>> {
if (!cfg.enabled) return err('disabled', 'The assistant is turned off in Settings.');
if (!cfg.apiKey.trim()) return err('no-key', 'No API key configured in Settings.');
if (!cfg.baseUrl.trim()) return err('network', 'No provider base URL configured.');
const wantJson = !!opts.schema;
const controller = new AbortController();
const onParentAbort = () => controller.abort();
if (opts.signal) opts.signal.addEventListener('abort', onParentAbort);
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT);
try {
const req = cfg.provider === 'anthropic' ? buildAnthropic(cfg, opts, wantJson) : buildOpenai(cfg, opts, wantJson);
let res: Response;
try {
res = await fetch(req.url, {
method: 'POST',
headers: req.headers,
body: JSON.stringify(req.body),
signal: controller.signal,
});
} catch (e) {
if (controller.signal.aborted) return err('timeout', 'The request timed out.');
const msg = e instanceof Error ? e.message : String(e);
if (/content security policy|csp/i.test(msg)) {
return err('csp', 'Blocked by content security policy. Check the provider base URL.');
}
return err('network', `Could not reach the provider. Check the base URL and your connection.`);
}
if (!res.ok) {
return err('http', `Provider returned HTTP ${res.status}. ${await safeText(res)}`.trim());
}
let json: unknown;
try {
json = await res.json();
} catch {
return err('parse', 'Provider returned a non-JSON response.');
}
const text = extractText(cfg.provider, json);
if (!wantJson) return { ok: true, text };
const parsed = tryParseJson(text);
if (parsed === undefined) return err('parse', 'The model did not return valid JSON.');
const result = opts.schema!.safeParse(parsed);
if (!result.success) return err('parse', 'The model output did not match the expected shape.');
return { ok: true, data: result.data };
} finally {
clearTimeout(timer);
if (opts.signal) opts.signal.removeEventListener('abort', onParentAbort);
}
}
/** Lightweight reachability/auth check used by the Settings "Test connection" button. */
export async function testConnection(cfg: LlmConfig): Promise<LlmResult<string>> {
return complete({ ...cfg, enabled: true }, {
system: 'You are a connectivity check. Reply with the single word: ok.',
user: 'ping',
maxTokens: 16,
timeoutMs: 15_000,
});
}
+44
View File
@@ -0,0 +1,44 @@
import type { ZodType } from 'zod';
export type LlmProvider = 'anthropic' | 'openai-compatible';
export interface LlmConfig {
enabled: boolean;
provider: LlmProvider;
model: string;
/** Base URL without a trailing slash. Anthropic: https://api.anthropic.com.
* OpenAI-compatible: e.g. https://api.openai.com/v1, https://openrouter.ai/api/v1,
* http://localhost:11434/v1 (Ollama), http://localhost:1234/v1 (LM Studio). */
baseUrl: string;
apiKey: string;
/** When false, the key is kept in memory only and never written to localStorage. */
rememberKey: boolean;
}
export type LlmErrorKind =
| 'disabled'
| 'no-key'
| 'timeout'
| 'network'
| 'csp'
| 'http'
| 'parse';
export type LlmResult<T> =
| { ok: true; data: T }
| { ok: true; text: string }
| { ok: false; error: LlmErrorKind; message: string };
export interface CompleteOptions<T = unknown> {
/** System prompt — must lead with the grounding/system constraint. */
system: string;
/** User message. */
user: string;
/** When provided, structured JSON output is requested and validated against it. */
schema?: ZodType<T>;
/** A short name describing the JSON shape (used by providers that require it). */
schemaName?: string;
signal?: AbortSignal;
timeoutMs?: number;
maxTokens?: number;
}