From f7fcd41341fe9bb4ff0f1720b0a2e4b7c37ac9a1 Mon Sep 17 00:00:00 2001 From: john kevin asprec Date: Mon, 13 Jul 2026 13:56:48 +0800 Subject: [PATCH] feat: implement API key resolution and live model listing for providers --- .claude/agents/lexai-extension-dev.md | 72 +++++++++++++++ CLAUDE.md | 119 +++++++++++++++++++++++++ RECOMMENDATIONS.md | 81 +++++++++++++++++ entrypoints/background.ts | 108 +++++++++++++++++++---- entrypoints/options/Options.tsx | 122 ++++++++++++++++++++++---- 5 files changed, 467 insertions(+), 35 deletions(-) create mode 100644 .claude/agents/lexai-extension-dev.md create mode 100644 CLAUDE.md create mode 100644 RECOMMENDATIONS.md diff --git a/.claude/agents/lexai-extension-dev.md b/.claude/agents/lexai-extension-dev.md new file mode 100644 index 0000000..aa9db98 --- /dev/null +++ b/.claude/agents/lexai-extension-dev.md @@ -0,0 +1,72 @@ +--- +name: lexai-extension-dev +description: >- + Specialist for the LexAI Chrome extension (WXT + React + Manifest V3, BYO-LLM-key). + Use for any work on entrypoints/ (content script, background service worker, options, + popup), the multi-provider LLM proxy, chrome.storage + tweetnacl key handling, message + passing between contexts, selection/replace DOM logic, or the Gitea CI / Chrome Web Store + release flow. Knows this repo's conventions (inline styles, data-lexai guard, snapshot + pattern, dual message shapes) and verifies changes with typecheck/tests/build. +tools: Read, Edit, Write, Grep, Glob, Bash, Skill +model: sonnet +--- + +You are the LexAI extension specialist. LexAI is a Grammarly-like **Manifest V3 Chrome +extension** built with **WXT + React + TypeScript**. It has **no backend** — the background +service worker calls the user's own LLM provider (OpenAI / Anthropic / Groq / OpenRouter) +with the user's own API key. Read `CLAUDE.md` at the repo root first; it is the source of +truth for architecture and conventions. + +## Your operating rules + +1. **Respect the three-context model.** Content script ⇄ background ⇄ React pages talk only + via `chrome.runtime` messages. Never make a provider `fetch` from the content script or a + React page — CORS and key handling belong in `entrypoints/background.ts`. Route through + `ANALYZE_TEXT` or `COPY_AS`. + +2. **Preserve the message contract.** `ANALYZE_TEXT` must accept both `{ payload: {...} }` + and flat `{ text, action, style }`. The `onMessage` listener must `return true`. Actions + are `grammar|rephrase|shorten|expand|explain`; `fix` normalizes to `grammar`. + +3. **Don't break the selection/replace pipeline** in `content.ts`. Selection is captured + eagerly (mouseup + button mousedown) and snapshotted before any `await`, because focus + and the live selection are gone by the time a response returns. Handle **both** paths: + textarea/input (`selectionStart/End`) and contenteditable/DOM (`Range` API). Keep the + `data-lexai="true"` attribute on every injected node. + +4. **Key security is non-negotiable.** Prefer the encrypted path (`apiKeyEnc` + `encKey`, + tweetnacl `secretbox`); plaintext `apiKey` is back-compat only. Never log the key, never + send it anywhere except the user's selected provider endpoint. Keep the plaintext fallback + unless you write a migration. + +5. **Styling is inline.** Tailwind is installed but inactive. Match the existing dark + Catppuccin-ish palette and inline `Object.assign(el.style, {...})` / `style={{...}}` + pattern. Don't introduce Tailwind classes unless the task is explicitly to wire up PostCSS. + +6. **When you add or change a provider,** remember each provider is duplicated as `callX` + and `callXWithPrompt`. Update both, and keep error handling uniform (network error → + friendly string; `!res.ok` → provider error message; empty result → explicit message). + +## Verify before you finish + +Run what the change touches, and report actual output: + +```bash +npm install # if node_modules is absent +npm run typecheck +npm test -- --run +npm run build # for behavior changes; confirms the MV3 bundle builds +``` + +For DOM/selection/replace changes, `npm run build` and state that a real-page manual check is +needed (load unpacked from `.output/chrome-mv3`) — unit tests do not cover DOM timing. Use the +`verify` and `run` skills when driving the built extension would confirm behavior. + +## Release awareness + +CI is **Gitea** (`.gitea/workflows/`), not GitHub Actions. Version lives in **both** +`package.json` and `wxt.config.ts`; a `v*.*.*` tag triggers the Chrome Web Store deploy. Flag +any change that would require a version bump or a manifest permission change. + +Be surgical: match existing style, keep diffs minimal, and explain any change that affects the +message contract, storage schema, manifest permissions, or the key-handling path. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b6a9b6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md — LexAI + +Guidance for Claude Code when working in this repository. + +## What LexAI is + +A Grammarly-like **Chrome Extension (Manifest V3)** that provides AI writing assistance +(grammar fix, rephrase, shorten, expand, explain) on any webpage. Users bring **their own +LLM API key** — there is no LexAI backend. The extension's service worker calls the user's +chosen provider directly. + +- **Providers:** OpenAI, Anthropic, Groq, OpenRouter (all configured in `entrypoints/background.ts`). +- **No subscription, no server.** The API key lives encrypted in `chrome.storage.local`. + +## Tech stack + +- **WXT** `^0.20` — extension framework (wraps Vite). Entrypoints live in `entrypoints/`. +- **React 18** + TypeScript — used only for the Options and Popup pages. +- **Zustand** — a dependency, but state is currently local; not yet wired into a store. +- **tweetnacl** / **tweetnacl-util** — `secretbox` symmetric encryption for the API key. +- **Vitest** (jsdom) for unit tests, **Playwright** for e2e. +- **Tailwind** is in devDependencies but **not active** — all UI uses inline style objects + (WXT PostCSS was never wired up). Do not assume Tailwind classes work. + +## Commands + +```bash +npm install # first-time setup (node_modules is gitignored; not present by default) +npm run dev # WXT dev server with hot reload +npm run build # production build → .output/chrome-mv3/ +npm run zip # package for Chrome Web Store +npm test # vitest (add `-- --run` for one-shot, non-watch) +npm run test:e2e # Playwright (requires a prior `npm run build`) +npm run typecheck # tsc --noEmit +``` + +**Prerequisite:** Node. CI pins **Node 22** (`node:22-bookworm`). Run `npm install` before any +`npm run *` script — the binaries (`tsc`, `vitest`) come from `node_modules/.bin`. + +**Load unpacked in Chrome:** `npm run build` → `chrome://extensions` → Developer Mode → +Load unpacked → select `.output/chrome-mv3`. + +## Architecture + +Three cooperating contexts, message-passed over `chrome.runtime`: + +``` +entrypoints/content.ts (content script, injected into ) + • Detects text selection: textarea/input (selectionStart/End) vs contenteditable/DOM (Range API) + • Renders the floating toolbar + result modal + toasts (all inline-styled, appended to document.body) + • Snapshots selection state BEFORE any async call, then Replace uses the snapshot + • Sends { type: 'ANALYZE_TEXT', payload: {text, action, style} } to the background + +entrypoints/background.ts (service worker — the LLM proxy) + • onMessage: ANALYZE_TEXT and COPY_AS + • Reads provider/apiKey/apiKeyEnc/encKey/model from chrome.storage.local + • Decrypts the key (tweetnacl secretbox), routes to the correct provider's fetch call + • Registers right-click context menus (action × style) on install + +entrypoints/options/Options.tsx (settings page, React) + • Provider + model + API key form; encrypts the key and writes apiKeyEnc/encKey to storage + +entrypoints/popup/Popup.tsx (toolbar popup, React) + • Standalone text box → same ANALYZE_TEXT flow; shows config status; links to Options +``` + +Content script and popup **must not** call provider APIs directly — CORS and key handling +belong in the background service worker. Route everything through `ANALYZE_TEXT`/`COPY_AS`. + +### Message contract + +- `ANALYZE_TEXT` accepts **both** `{ payload: {text, action, style} }` (content/popup) and + flat `{ text, action, style }`. Keep both shapes working if you touch the handler. +- `action` values: `grammar`, `rephrase`, `shorten`, `expand`, `explain`. The context menu + and popup emit `fix`, which `getSystemPrompt` normalizes to `grammar`. +- The listener returns `true` to keep the async channel open — **required**; removing it + silently breaks every response. + +## Key conventions & gotchas + +- **`data-lexai="true"`** is set on every LexAI-injected DOM node. Selection/click handlers + check `target.closest('[data-lexai="true"]')` to avoid self-triggering. Preserve it on any + new injected element. +- **Selection is captured eagerly** (on `mouseup` and on button `mousedown`) because focus + shifts and the live selection is gone by the time an async response returns. When editing + content.ts, keep the snapshot-before-await pattern intact. +- **`z-index: 2147483647`** (max) on toolbar/modal so they sit above host-page UI. +- **Provider code is duplicated**: each provider has a `callX` (system-prompt from action) + and a `callXWithPrompt` (arbitrary system prompt, used by COPY_AS). A change to request + shape usually needs to be made in both. See "Recommendations" below — this is a known smell. +- **API-key handling:** prefer the encrypted path (`apiKeyEnc` + `encKey`); plaintext `apiKey` + is legacy/back-compat only. Never log the key. Never add code that transmits it anywhere + except the user's chosen provider endpoint. +- **Backward compat:** don't drop the plaintext `apiKey` fallback without a migration. +- Console `[LexAI …]` debug logs exist in content.ts's replace path — intentional for now. + +## Testing notes + +- `tests/unit/setup.ts` mocks `global.chrome`. Unit tests currently exercise storage mocks + rather than importing the real handlers — see Recommendations for the gap. +- Playwright e2e loads the built extension via `--load-extension=.output/chrome-mv3`; the + test files still contain `[EXTENSION_ID]` placeholders and won't pass as-is. + +## CI / release (Gitea, not GitHub Actions) + +Workflows live in `.gitea/workflows/`: +- `ci.yml` — typecheck → test → build → publish zip to Gitea package registry (on push to main/develop, PRs). +- `deploy-chrome.yml` — on `v*.*.*` tag: build → upload → publish to Chrome Web Store. +- Both send Telegram notifications. Secrets: `GITEATOKEN`, `CWS_*`, `TELEGRAM_*`. + +**Version bumps:** update `version` in **both** `package.json` and `wxt.config.ts` (the +manifest version comes from wxt.config.ts). A `v*.*.*` git tag triggers the store deploy. + +## When making changes + +- After editing an entrypoint, run `npm run typecheck` and `npm test -- --run`. +- For behavior changes, `npm run build` and load unpacked to verify in a real page — the + selection/replace logic is DOM-timing-sensitive and unit tests don't cover it. +- Keep UI styling inline (no Tailwind) unless you're intentionally wiring PostCSS. diff --git a/RECOMMENDATIONS.md b/RECOMMENDATIONS.md new file mode 100644 index 0000000..ab5be34 --- /dev/null +++ b/RECOMMENDATIONS.md @@ -0,0 +1,81 @@ +# LexAI — Improvement Recommendations + +Prioritized findings from a full read of the codebase (2026-07-13). Grouped by theme; +each item notes rough effort (S/M/L) and why it matters. + +## 🔒 Security & privacy + +1. **Narrow host permissions (M).** The manifest requests `` host permission and + injects the content script into every frame of every site — including banking, email, and + internal apps. Consider `activeTab` + on-demand injection, or a user-configurable + allowlist. This is also the #1 thing that slows Chrome Web Store review. +2. **The encryption is obfuscation, not protection (M).** `encKey` sits in + `chrome.storage.local` right next to `apiKeyEnc`; anyone who can read storage can decrypt. + Be honest in the UI ("stored locally, obscured") or derive the key from something not + co-located (e.g. `chrome.storage.session` for the key, WebCrypto, or a passphrase). At + minimum, don't over-promise "encrypted" security to users. +3. **Strip debug logging from production (S).** `content.ts` logs selection text and element + values (`[LexAI captureForButton]`, `[LexAI Replace]`, etc.) to the page console — visible + to the host page. Gate behind a `__DEV__`/`import.meta.env.DEV` flag. + +## 🧹 Code quality & maintainability + +4. **Collapse the duplicated provider layer (M).** Each provider exists twice — + `callOpenAI`/`callOpenAIWithPrompt`, etc. — 8 near-identical functions. Refactor to one + `callProvider(config, messages | systemPrompt, text)` with a small per-provider adapter + describing `{ url, headers(config), body(model, system, text), extract(data) }`. Cuts + `background.ts` roughly in half and removes the "update both copies" trap noted in CLAUDE.md. +5. **Extract shared UI/styling (M).** The Catppuccin palette and button styles are re-declared + inline across content.ts, Options.tsx, Popup.tsx. Move colors/spacing into a shared + `src/ui/theme.ts` (and reusable style factories) so a palette change is one edit. +6. **Remove dead dependencies (S).** `zustand` is installed but no store exists; `tailwindcss` + + `autoprefixer` are present but inactive. Either wire them up or drop them to shrink the + install and remove confusion. +7. **Centralize provider/model config (S).** The provider list, default models, and endpoints + live in both `Options.tsx` (UI) and `background.ts` (calls). Put them in one shared module + so the picker and the caller can't drift. + +## ✅ Testing (biggest gap) + +8. **Unit tests don't test real code (M).** `tests/unit/background.test.ts` only exercises the + `chrome.storage` mock — it never imports `getSystemPrompt`, `decryptApiKey`, or the provider + router. Extract those pure functions and test them directly (prompt normalization, + `fix`→`grammar`, encrypt→decrypt round-trip, provider routing, error extraction). +9. **Fix or quarantine the e2e tests (S).** `tests/e2e/extension.test.ts` hard-codes + `chrome-extension://[EXTENSION_ID]/...` — it cannot pass. Resolve the extension ID at + runtime (read it from the service-worker target) or mark the suite `.skip` until fixed so + CI green means something. +10. **Add a content-script DOM test (L).** The selection→snapshot→replace logic is the app's + riskiest code and has zero coverage. A jsdom or Playwright test over textarea and + contenteditable replace paths would catch regressions the current tests can't. + +## ✨ Product / UX + +11. **Make `max_tokens` adaptive (S).** It's hard-coded to `1024` everywhere; "Expand" on a + long paragraph will truncate mid-sentence. Scale with input length or expose it in settings. +12. **Add response streaming (L).** Non-streaming means the user stares at "thinking…" for the + full latency. Streaming tokens into the modal is the single biggest perceived-speed win. +13. **Accessibility (M).** Toolbar/modal buttons lack `aria-label`s, focus management, and + keyboard navigation; the modal doesn't trap focus. Add roles/labels and Esc/Tab handling + (Esc is partially handled already). +14. **React error boundaries + graceful storage failures (S).** Options/Popup call + `createRoot(...).render()` with no error boundary; a throw yields a blank page. + +## 🚀 Build / release + +15. **Pin the toolchain (S).** Add an `.nvmrc`/`engines` field for Node 22 to match CI, and a + `package.json` `packageManager` field. Local `npm run *` currently fails with no + `node_modules` and no version guard. +16. **Version bump is a two-file manual step (S).** `version` must be edited in both + `package.json` and `wxt.config.ts`. Add a script (or read one from the other) so a release + can't ship mismatched versions — this has already caused churn in the git history. +17. **CI clones instead of checking out (S).** Both Gitea workflows `git clone` the repo into + `/tmp` rather than using the checked-out workspace, and disable TLS verification + (`http.sslVerify false`). Worth revisiting for speed and security once the runner setup + allows a normal checkout. + +## Suggested order + +Quick wins first: **3, 6, 9, 11, 15, 16** (all S, mostly independent). Then the structural +refactors **4, 5, 8**, which make everything after them easier. Tackle **1/2** (permissions + +key story) before any serious Chrome Web Store push. Save **10, 12, 13** for a focused Phase 2. diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 38bd73e..eaee598 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -80,6 +80,18 @@ async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise { + let apiKey = config.apiKey; + if (config.apiKeyEnc && config.encKey) { + const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc); + if (decrypted) apiKey = decrypted; + } + if (!apiKey || apiKey.trim() === '') return null; + return apiKey.trim(); +} + // ─── Provider implementations ───────────────────────────────────────────────── async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise { @@ -353,26 +365,70 @@ async function callOpenRouterWithPrompt(payload: AnalyzePayload, config: LexAICo return { result: result.trim() }; } +// ─── Live model listing ─────────────────────────────────────────────────────── + +const MODEL_LIST_ENDPOINTS: Record = { + openai: 'https://api.openai.com/v1/models', + groq: 'https://api.groq.com/openai/v1/models', + openrouter: 'https://openrouter.ai/api/v1/models', + anthropic: 'https://api.anthropic.com/v1/models', +}; + +// Drop non-chat models (embeddings, audio, image, etc.) so the picker stays useful. +const NON_CHAT_MODEL_RE = /embedding|whisper|tts|dall-e|audio|realtime|moderation|image|guard|transcribe|speech|rerank/i; + +async function listModels(provider: string, apiKey?: string): Promise<{ models?: string[]; error?: string }> { + const url = MODEL_LIST_ENDPOINTS[provider]; + if (!url) return { error: `Unknown provider: "${provider}". Please check LexAI settings.` }; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (provider === 'anthropic') { + if (!apiKey) return { error: 'Anthropic requires an API key to list models.' }; + headers['x-api-key'] = apiKey; + headers['anthropic-version'] = '2023-06-01'; + // Allow the extension origin to call Anthropic directly (avoids a CORS 403). + headers['anthropic-dangerous-direct-browser-access'] = 'true'; + } else if (apiKey) { + // OpenRouter's list is public, so the key is optional there; OpenAI/Groq require it. + headers['Authorization'] = `Bearer ${apiKey}`; + } + + let res: Response; + try { + res = await fetchWithTimeout(url, { method: 'GET', headers }, 15000); + } catch (err) { + return { error: `Network error reaching ${provider}: ${String(err)}` }; + } + + const data = await res.json().catch(() => null); + if (!res.ok) { + const msg = data?.error?.message ?? data?.error ?? `HTTP ${res.status}`; + return { error: `${provider} error: ${msg}` }; + } + + const raw = Array.isArray(data?.data) ? data.data : []; + const ids = raw + .map((m: any) => (typeof m === 'string' ? m : m?.id)) + .filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) + .filter((id: string) => !NON_CHAT_MODEL_RE.test(id)) + .sort((a: string, b: string) => a.localeCompare(b)); + + if (ids.length === 0) return { error: `No models returned by ${provider}.` }; + return { models: ids }; +} + // ─── Main handler ───────────────────────────────────────────────────────────── async function handleAnalyzeText(payload: AnalyzePayload): Promise { const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']); const config = stored as LexAIConfig; - // Resolve API key — prefer encrypted path, fall back to plaintext for backward compat - let apiKey = config.apiKey; - if (config.apiKeyEnc && config.encKey) { - const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc); - if (decrypted) { - apiKey = decrypted; - } - } - - if (!apiKey || apiKey.trim() === '') { + const apiKey = await resolveApiKey(config); + if (!apiKey) { return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' }; } - const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() }; + const resolvedConfig: LexAIConfig = { ...config, apiKey }; const provider = config.provider || 'openai'; switch (provider) { @@ -460,16 +516,12 @@ export default defineBackground(() => { chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']) .then(async (stored) => { const config = stored as LexAIConfig; - let apiKey = config.apiKey; - if (config.apiKeyEnc && config.encKey) { - const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc); - if (decrypted) apiKey = decrypted; - } - if (!apiKey || apiKey.trim() === '') { + const apiKey = await resolveApiKey(config); + if (!apiKey) { sendResponse({ error: 'No API key configured. Please open LexAI settings.' }); return; } - const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() }; + const resolvedConfig: LexAIConfig = { ...config, apiKey }; const systemPrompt = `Reformat the following text as ${format}. Return only the reformatted result, no explanation.`; return callProvider(resolvedConfig, text, systemPrompt); }) @@ -477,5 +529,25 @@ export default defineBackground(() => { .catch((err) => sendResponse({ error: String(err) })); return true; } + + if (message.type === 'LIST_MODELS') { + const provider = (message.provider as string) || 'openai'; + // Prefer an inline key (freshly typed, not yet saved); else use the stored key. + const inlineKey = (message.apiKey as string | undefined)?.trim() || undefined; + (async () => { + let apiKey = inlineKey; + if (!apiKey) { + const stored = await chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey']); + apiKey = (await resolveApiKey(stored as LexAIConfig)) ?? undefined; + } + if (!apiKey && provider !== 'openrouter') { + return { error: 'No API key found. Enter your API key above, then click Load models.' }; + } + return listModels(provider, apiKey); + })() + .then(sendResponse) + .catch((err) => sendResponse({ error: String(err) })); + return true; + } }); }); diff --git a/entrypoints/options/Options.tsx b/entrypoints/options/Options.tsx index 3274767..e607c01 100644 --- a/entrypoints/options/Options.tsx +++ b/entrypoints/options/Options.tsx @@ -4,33 +4,30 @@ import nacl from 'tweetnacl'; // ─── Provider config ────────────────────────────────────────────────────────── +// Model lists are fetched live per provider (see loadModels) — no static lists here. const PROVIDERS = [ { id: 'openai', name: 'OpenAI', placeholder: 'sk-...', - models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'], docsUrl: 'https://platform.openai.com/api-keys', }, { id: 'anthropic', name: 'Anthropic (Claude)', placeholder: 'sk-ant-...', - models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'], docsUrl: 'https://console.anthropic.com/keys', }, { id: 'groq', name: 'Groq (Free tier)', placeholder: 'gsk_...', - models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'], docsUrl: 'https://console.groq.com/keys', }, { id: 'openrouter', name: 'OpenRouter (100+ models)', placeholder: 'sk-or-...', - models: ['openai/gpt-4o-mini', 'anthropic/claude-3-5-haiku', 'meta-llama/llama-3.3-70b-instruct:free'], docsUrl: 'https://openrouter.ai/keys', }, ]; @@ -193,19 +190,63 @@ function safeStorageSet(data: Record, callback?: () => void) { } } +async function safeSendMessage(message: Record): Promise { + try { + if (typeof chrome === 'undefined' || !chrome.runtime?.id) return null; + return await chrome.runtime.sendMessage(message); + } catch (err) { + console.warn('LexAI: sendMessage failed', err); + return null; + } +} + // ─── Component ──────────────────────────────────────────────────────────────── function OptionsPage() { const [provider, setProvider] = useState('openai'); const [apiKey, setApiKey] = useState(''); - const [model, setModel] = useState('gpt-4o-mini'); + const [model, setModel] = useState(''); + const [models, setModels] = useState([]); + const [modelsStatus, setModelsStatus] = useState<'idle' | 'loading' | 'error'>('idle'); + const [modelsError, setModelsError] = useState(''); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [showKey, setShowKey] = useState(false); const [isEncrypted, setIsEncrypted] = useState(false); const apiKeyRef = useRef(null); + // Fetch the live model list from the provider (via the background worker, which + // owns key decryption). Uses a freshly typed key if present, else the stored key. + const loadModels = async (providerOverride?: string, keyOverride?: string) => { + const prov = providerOverride ?? provider; + const inlineKey = keyOverride ?? (apiKey.trim() || undefined); + setModelsStatus('loading'); + setModelsError(''); + + const response = await safeSendMessage({ type: 'LIST_MODELS', provider: prov, apiKey: inlineKey }); + + if (!response) { + setModels([]); + setModelsStatus('error'); + setModelsError('No response from the extension. Try reloading the page.'); + return; + } + if (response.error) { + setModels([]); + setModelsStatus('error'); + setModelsError(response.error); + return; + } + + const list: string[] = Array.isArray(response.models) ? response.models : []; + setModels(list); + setModelsStatus('idle'); + // Keep the current selection if it's still valid; otherwise force a re-pick. + setModel((prev) => (prev && list.includes(prev) ? prev : '')); + }; + useEffect(() => { safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => { + const prov = result.provider || 'openai'; if (result.provider) setProvider(result.provider); if (result.model) setModel(result.model); @@ -217,6 +258,11 @@ function OptionsPage() { setApiKey(result.apiKey); setIsEncrypted(false); } + + // If a key is already configured, pull the live model list on open. + if (result.apiKeyEnc || result.apiKey || prov === 'openrouter') { + loadModels(prov); + } }); }, []); @@ -224,11 +270,20 @@ function OptionsPage() { const handleProviderChange = (newProvider: string) => { setProvider(newProvider); - const p = PROVIDERS.find((p) => p.id === newProvider); - if (p) setModel(p.models[0]); + setModel(''); + setModels([]); + setModelsStatus('idle'); + setModelsError(''); + loadModels(newProvider); }; const handleSave = async () => { + // Require an explicit model choice — prevents sending a stale/mismatched model + // id to a provider (the root cause of the OpenRouter failures). + if (!model) { + setModelsError('Please load and select a model before saving.'); + return; + } // If key is blank and we already have an encrypted key, don't overwrite if (!apiKey.trim() && !isEncrypted) { apiKeyRef.current?.focus(); @@ -319,18 +374,51 @@ function OptionsPage() { - {/* Model */} + {/* Model — fetched live from the selected provider */}
- +
+ + +
+ {modelsStatus === 'error' && modelsError && ( +
⚠ {modelsError}
+ )}
{/* API Key */}