feat: implement API key resolution and live model listing for providers
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 59s

This commit is contained in:
john kevin asprec
2026-07-13 13:56:48 +08:00
parent 50df1b5d93
commit f7fcd41341
5 changed files with 467 additions and 35 deletions

View File

@@ -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.

119
CLAUDE.md Normal file
View File

@@ -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 <all_urls>)
• 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.

81
RECOMMENDATIONS.md Normal file
View File

@@ -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 `<all_urls>` 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.

View File

@@ -80,6 +80,18 @@ async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise<s
return new TextDecoder().decode(decrypted);
}
// Resolve the usable API key from stored config: prefer the encrypted path,
// fall back to plaintext for backward compat. Returns null if none is set.
async function resolveApiKey(config: LexAIConfig): Promise<string | null> {
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<LexAIResponse> {
@@ -353,26 +365,70 @@ async function callOpenRouterWithPrompt(payload: AnalyzePayload, config: LexAICo
return { result: result.trim() };
}
// ─── Live model listing ───────────────────────────────────────────────────────
const MODEL_LIST_ENDPOINTS: Record<string, string> = {
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<string, string> = { '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<LexAIResponse> {
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;
}
});
});

View File

@@ -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<string, string>, callback?: () => void) {
}
}
async function safeSendMessage(message: Record<string, unknown>): Promise<any> {
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<string[]>([]);
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<HTMLInputElement>(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() {
</select>
</div>
{/* Model */}
{/* Model — fetched live from the selected provider */}
<div style={styles.formGroup}>
<label style={styles.label}>Model</label>
<select
style={styles.select}
value={model}
onChange={(e) => setModel(e.target.value)}
>
{currentProvider.models.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<div style={{ display: 'flex', gap: '8px' }}>
<select
style={{ ...styles.select, flex: 1, opacity: models.length === 0 ? 0.6 : 1 }}
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={modelsStatus === 'loading' || models.length === 0}
>
{modelsStatus === 'loading' && <option value="">Loading models</option>}
{modelsStatus !== 'loading' && models.length === 0 && (
<option value="">
{modelsStatus === 'error' ? 'Failed to load — see below' : 'Load models to choose'}
</option>
)}
{models.length > 0 && <option value=""> Select a model </option>}
{models.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button
type="button"
onClick={() => loadModels()}
disabled={modelsStatus === 'loading'}
title="Fetch the current model list from your provider"
style={{
background: 'rgba(137,180,250,0.15)',
color: '#89b4fa',
border: '1px solid rgba(137,180,250,0.3)',
borderRadius: '9px',
padding: '0 12px',
fontSize: '13px',
fontWeight: 600,
cursor: modelsStatus === 'loading' ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{modelsStatus === 'loading' ? '⏳' : '↻ Load'}
</button>
</div>
{modelsStatus === 'error' && modelsError && (
<div style={{ ...styles.hint, color: '#f38ba8' }}> {modelsError}</div>
)}
</div>
{/* API Key */}