diff --git a/CLAUDE.md b/CLAUDE.md index b276cd7..5c8d4d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,7 @@ Workflows live in `.gitea/workflows/`: - `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. (Single-sourcing tracked in T-16.) +**Version bumps:** edit `version` in `package.json` only — `wxt.config.ts` reads `pkg.version`, so the manifest follows automatically (T-16 done). Use `npm version --no-git-tag-version` so `package-lock.json` stays in sync. A `v*.*.*` git tag triggers the store deploy **and publishes it live** (`deploy-chrome.yml:91`). ## Orchestration & agents @@ -166,7 +166,7 @@ Codebase invariants that break silently when violated (detail + evidence in `doc - Never log or transmit the API key except to the user's provider; keep the plaintext `apiKey` fallback until a migration exists. - Update both `callX` and `callXWithPrompt` when changing a provider's request shape. - Never `fetch` a provider from content/popup — route through the background worker. -- Bump `version` in both `package.json` and `wxt.config.ts`. +- Bump `version` in `package.json` only (+ lockfile); the manifest derives it via `pkg.version`. - Style inline; Tailwind classes do nothing until PostCSS is wired. ## Memory protocol diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 8df49d1..17bcfcc 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,26 +3,14 @@ ## Current state - **Outcome:** Phase 1 complete (7 workitems, 2026-03-06). Codebase reviewed 2026-07-13 (`RECOMMENDATIONS.md`). Operating-system docs + agent roster aligned to the template 2026-07-15. -- **Delivered:** working MV3 extension — selection detection (textarea/input/contenteditable), floating toolbar, background LLM proxy with OpenAI/Anthropic/Groq/OpenRouter, Options page, result modal with Replace/Copy. Build ~166 KB. -- **Verified (Phase 1):** `npm run build` clean. Note: unit tests currently exercise the storage mock, not the real handlers; e2e has `[EXTENSION_ID]` placeholders and won't pass as-is. -- **Verified (2026-07-15 finalize/build):** `npm run typecheck` clean, `npm test -- --run` 46/46 passing (actions, messaging, crypto, providers), `npm run build` clean → `.output/chrome-mv3/` (265.82 kB) ready for load-unpacked testing. Recent refactor series (crypto consolidation, provider adapter table, context-menu registry, dev-gated debug logs) all pass gates; real-page Replace verification still pending on the user's load-unpacked check. -- **Fix (2026-07-15):** OpenAI adapter now sends `max_completion_tokens` instead of the legacy `max_tokens` (newer OpenAI models reject it) and omits `temperature` for reasoning models (`o*`/`gpt-5*`, which only accept the default). Groq/OpenRouter unchanged (they still expect `max_tokens`). Pinned tests updated + new reasoning-model test; typecheck/47 tests/build all green; rebuilt `.output/chrome-mv3/`. -- **Feature (2026-07-15):** Options form reordered to Provider → API Key → Model (model list is fetched with the key). New `prompt` action ("Make Prompt", prompt-engineer): added to `ACTIONS`/labels (context menus follow automatically), prompt-engineer system prompt in `getSystemPrompt` with a prompt-directed style modifier, toolbar button (🪄 Prompt) in content.ts, popup button. Tests updated (registry count now derived; new getSystemPrompt cases); typecheck/48 tests/build green. -- **Feature (2026-07-15, Prompt Builder):** dedicated popup section for the `prompt` action with its own parameters — Prompt Style (Auto/Instructional/Role-play/Step-by-step/Few-shot/Structured), Persona (presets + Custom free text + None), Output Format (Auto/Plain/Markdown/Bulleted/Numbered/JSON/Table). Constants in `src/lib/actions.ts`, `PromptParams` added to `AnalyzePayload` (both message shapes still supported), composed into the system prompt by `promptParamModifiers` in providers.ts (prompt action only; 'Auto' = no-op). Params persist in `chrome.storage.local`. Toolbar 🪄 Prompt keeps Auto defaults. typecheck/49 tests/build green. -- **Feature (2026-07-15, popup tabs + model picker):** popup restructured into two tabs — "✍ Writing" (style + fix/rephrase/shorten/expand) and "🪄 Prompt Builder" (prompt params + Make Prompt). Prompt Builder gained a Model picker: fetched via `LIST_MODELS` on first tab open, '' = configured default; selection sent as new optional `model` override on `AnalyzePayload` (both message shapes), applied in background's `handleAnalyzeText`. Tab + model persist in storage. typecheck/49 tests/build green. -- **Tweak (2026-07-15):** model picker's "Default (model)" label → plain "Default" (badge already shows the model). Toolbar/context-menu `prompt` requests now inherit the saved Prompt Builder settings: background's `handleAnalyzeText` loads `promptStyle/promptPersona/customPersona/promptFormat/promptModel` from storage when the payload has no `promptParams` (popup still sends explicit ones); persona resolution shared via `resolvePromptPersona` in actions.ts. typecheck/49 tests/build green. -- **Feature (2026-07-15, in-page Prompt Builder dialog):** toolbar 🪄 Prompt and context-menu "Make Prompt" now open an on-page dialog (content.ts `showPromptBuilderDialog`) with the popup's parameters (Style/Persona+custom/Format/Model); selections persist to the shared storage keys, then the request runs without explicit params (background applies saved). Context menu: prompt is now a single item (no style children — registry excludes it; tests updated). `LIST_MODELS` resolves provider from storage when omitted. Toolbar re-clamps position using its real width so the last buttons stay on-screen. typecheck/49 tests/build green. Needs a real-page check (dialog + replace are DOM-timing-sensitive). -- **Fix (2026-07-15, prompt UX chain):** Make Prompt no longer closes the dialog silently — the dialog becomes a "⟳ Building your prompt…" spinner and `runAction` closes it (`closePromptBuilder`) at every completion path (success, error, invalidated-context). Prompt result modal is prompt-specific: "🪄 Engineered Prompt" title, and the writing-style selector row is replaced by "✎ Edit Parameters" (reopens the builder dialog) + Regenerate (re-runs with saved builder params). typecheck/49 tests/build green. -- **Security/perf pass (2026-07-15, goal-driven audit):** reviewed key safety, user-text privacy, and content-script performance; fixed: - - `src/lib/crypto.ts` — new `migratePlaintextApiKey()`: background auto-encrypts a legacy plaintext `apiKey` on worker start and removes the plaintext (write-and-await key material before delete; verify-decrypt before dropping plaintext when an encrypted key already exists; re-check for a concurrent Options save before writing). Read-path plaintext fallback in `resolveApiKey` retained per invariant. - - `entrypoints/background.ts` — calls the migration on startup; `sender.id !== chrome.runtime.id` guard on `onMessage` (defense-in-depth; internal senders unaffected). - - `entrypoints/content.ts` — fixed unbounded document-listener leak: `showToolbar` added `click`/`scroll` listeners per selection and never removed them; now unregistered in `hideToolbar` (also closes an orphaned More-menu). Error toast gained `data-lexai`; mouseup threshold now uses `MIN_SELECTION_LENGTH` (was hardcoded `<= 10`, which ate exactly-10-char selections); same sender guard on its listener. - - `src/lib/providers.ts` — `callProvider` guards `res.json()` so non-JSON gateway errors (HTML 502) surface as `" error: HTTP "` instead of a raw SyntaxError. - - Verified: typecheck clean, 54/54 tests (6 new: 4 migration, 1 non-JSON error, plus existing), build clean (280.3 kB). `security-auditor` reviewed the key-path diff: PASS; its two P3 hardening notes (await encKey persistence, concurrent-save re-check) were implemented and re-gated. User-text privacy audited clean: no persistence of analyzed text, debug logs dev-gated, key/text travel only to the chosen provider. Real-page load-unpacked check of toolbar/replace still recommended (DOM-timing paths untouched except listener cleanup). -- **CI fix + workflow hardening (2026-07-15):** CI typecheck failed on push (`content.ts(10,31) TS2339: Property 'env' does not exist on type 'ImportMeta'`) because `.wxt/types/` (which types `import.meta.env`) is generated by `wxt prepare`, which CI never ran — locally it existed as a side effect of `wxt build`/`dev`. Reproduced locally by deleting `.wxt/`. Fixes: `postinstall: wxt prepare` in package.json (root fix — every fresh install regenerates types); explicit "Prepare WXT types" step in both workflows (survives a future `--ignore-scripts`); `deploy-chrome.yml` now runs **typecheck** before tests (release previously gated less than CI); fixed `head -1` → `sed '$d'` body extraction in both curl-response checks (body is all-but-last-line, not first line); added `timeout-minutes` (15 CI / 20 deploy). Verified: fresh install regenerates `.wxt`, then typecheck + 54/54 tests + build all green. CLAUDE.md Commands section documents the gotcha. -- **Changed paths (this alignment):** added `docs/` (brief, architecture, decisions, tasks, evals, lessons, handoff, self-model, attacksurface), ported `.claude/agents/*` roster + `.claude/skills/*`, kept `lexai-extension-dev`, updated `.claude/AGENTS.md`. `CLAUDE.md` restructured to the operating-system format (all original LexAI rules preserved). +- **Delivered:** working MV3 extension — selection detection (textarea/input/contenteditable), floating toolbar, background LLM proxy with OpenAI/Anthropic/Groq/OpenRouter, Options page, result modal with Replace/Copy. +- **Shipped 2026-07-15 (consolidated):** refactor series (crypto consolidation, provider adapter table, context-menu registry, dev-gated debug logs); OpenAI `max_completion_tokens` + no-temperature for reasoning models; live model listing in Options (Provider → API Key → Model); new `prompt` action + Prompt Builder (popup tabs, in-page dialog, persona/style/format/model params shared via storage); security/perf pass (plaintext-key migration, `sender.id` guard, content-script listener leak fix, non-JSON error guard); CI fix — `postinstall: wxt prepare` (CI never ran it, so `.wxt/types` was missing and typecheck failed on `import.meta.env`) plus workflow hardening. All gated: typecheck + tests + build. +- **Fix (2026-07-23, Anthropic CORS):** `src/lib/providers.ts:172` — the Anthropic chat spec now sends `anthropic-dangerous-direct-browser-access: 'true'` (the model-list path at `:276` already did). Verified present in `.output/chrome-mv3/background.js`, which is the only bundle that reaches `api.anthropic.com`. **Unresolved for the user:** the CORS error still appears in their browser, which means the running extension is older than this build (a stale service worker, or a second copy installed from the pre-fix `.output/lexai-1.0.1-chrome.zip` dated 7/15). Next diagnostic: service-worker inspector → Network → `messages` → check Request Headers. +- **Fix (2026-07-23, Groq key rejected):** the Groq spec was correct; the Options flow was not. (1) `handleSave` set `modelsError` but the render gated it on `modelsStatus === 'error'`, so both save-time guards were invisible and Save silently no-opped — now rendered whenever set (amber for guidance, red for load errors). (2) `handleProviderChange` auto-listed models with the *stored* key after a provider switch, so Groq rejected the previous provider's key ("Invalid API Key") before any Groq key was entered — now tracked via `savedKeyProvider` ref; it prompts for the new key instead of guessing. (3) `listModels` errors now use `spec.label` (`Groq error: …`, matching the chat path) instead of the raw id (`groq error: …`). +- **Root cause + fix (2026-07-23, Groq "Invalid API Key"):** a stored key had no record of the provider it was entered for. `Options.handleSave` writes `{provider, model}` **without** the key when the field is blank and one is stored, so switching to Groq and saving left the OpenAI key attached to Groq — every call, and every stored-key model list, sent it and got that provider's own rejection while the field still showed 🔒. Fix: new `keyProvider` storage field (`types.ts`, in `CONFIG_STORAGE_KEYS`) written on every save; `keyProviderMismatch()` in background.ts blocks the send on the chat, COPY_AS, and stored-key LIST_MODELS paths with an actionable message (absent `keyProvider` = pre-upgrade key, allowed); Options drops the 🔒 badge and demands a new key when the saved one belongs to another provider or comes back rejected (`keyRejected` flag from `listModels` on 401/403); `callProvider` appends "open LexAI Settings and re-enter your API key" to 401/403 only. +- **Verified (2026-07-23):** `npm run typecheck` clean, `npm test -- --run` 58/58 (new: Groq bearer auth, labelled errors, the 401 hint, `keyRejected`, `providerLabel`), `npm run build` clean → `.output/chrome-mv3/` 281.72 kB. Options-page behavior is **not** covered by unit tests — a load-unpacked check of the Groq re-entry flow is still pending. - **Open risks (ranked):** 1. `` host permission — privacy surface + CWS review blocker (TASKS #1). 2. Key "encryption" is obfuscation (`encKey` co-located) — TASKS #2. - 3. Tests don't cover real code paths (TASKS #8) or DOM replace (TASKS #10). -- **Next smallest action:** run the quick wins in order — T-03 (gate debug logs), then T-06/T-09/T-15/T-16 — each is small and independent. Do T-01/T-02 before any Chrome Web Store push. + 3. Tests don't cover real code paths (TASKS #8), DOM replace (TASKS #10), or any Options/Popup React flow. +- **Next smallest action:** reload the unpacked extension, then in Options **re-enter the Groq key** (this stamps `keyProvider` and replaces the mis-attached key) → ↻ Load → select model → Save, and confirm a real-page action. Then the quick wins: T-03, T-06/T-09/T-15/T-16. Do T-01/T-02 before any Chrome Web Store push. diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 4515237..0e06039 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,8 +1,9 @@ import { defineBackground } from 'wxt/utils/define-background'; +import { CONFIG_STORAGE_KEYS } from '@lib/types'; import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types'; import { CONTEXT_MENU_ENTRIES, findContextMenuEntry, resolvePromptPersona } from '@lib/actions'; import { decryptApiKey, migratePlaintextApiKey } from '@lib/crypto'; -import { callProvider, getSystemPrompt, listModels } from '@lib/providers'; +import { callProvider, getSystemPrompt, listModels, providerLabel } from '@lib/providers'; // 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. @@ -16,10 +17,19 @@ async function resolveApiKey(config: LexAIConfig): Promise { return apiKey.trim(); } +// Options can save a provider change without touching the stored key, which +// leaves e.g. an OpenAI key attached to Groq — every call then fails with the +// provider's own "Invalid API Key". Detect it here and say what to do instead. +// `keyProvider` is absent for keys saved by older builds: unknown, so allow it. +function keyProviderMismatch(config: LexAIConfig, provider: string): string | null { + if (!config.keyProvider || config.keyProvider === provider) return null; + return `Your saved API key was entered for ${providerLabel(config.keyProvider)}, but the selected provider is ${providerLabel(provider)}. Open LexAI Settings and enter a ${providerLabel(provider)} API key.`; +} + // ─── Main handler ───────────────────────────────────────────────────────────── async function handleAnalyzeText(payload: AnalyzePayload): Promise { - const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']); + const stored = await chrome.storage.local.get([...CONFIG_STORAGE_KEYS]); const config = stored as LexAIConfig; // Prompt requests from the toolbar/context menu carry no explicit params — @@ -45,6 +55,9 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise { if (message.type === 'COPY_AS') { const { text, format } = message as { text: string; format: string }; - chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']) + chrome.storage.local.get([...CONFIG_STORAGE_KEYS]) .then(async (stored) => { const config = stored as LexAIConfig; const apiKey = await resolveApiKey(config); @@ -121,6 +134,11 @@ export default defineBackground(() => { sendResponse({ error: 'No API key configured. Please open LexAI settings.' }); return; } + const mismatch = keyProviderMismatch(config, config.provider || 'openai'); + if (mismatch) { + sendResponse({ error: mismatch }); + return; + } 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); @@ -143,7 +161,11 @@ export default defineBackground(() => { } let apiKey = inlineKey; if (!apiKey) { - const stored = await chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey']); + // Stored-key path: refuse to send a key that belongs to a different + // provider — it would come back as that provider's "invalid key". + const stored = await chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey', 'keyProvider']); + const mismatch = keyProviderMismatch(stored as LexAIConfig, provider); + if (mismatch) return { error: mismatch, keyRejected: true }; apiKey = (await resolveApiKey(stored as LexAIConfig)) ?? undefined; } if (!apiKey && provider !== 'openrouter') { diff --git a/entrypoints/options/Options.tsx b/entrypoints/options/Options.tsx index a6e00a5..eaca53f 100644 --- a/entrypoints/options/Options.tsx +++ b/entrypoints/options/Options.tsx @@ -150,6 +150,9 @@ const styles = { } as React.CSSProperties, }; +const providerName = (id: string | null): string => + PROVIDERS.find((p) => p.id === id)?.name ?? id ?? 'another provider'; + // ─── Component ──────────────────────────────────────────────────────────────── function OptionsPage() { @@ -163,6 +166,10 @@ function OptionsPage() { const [showKey, setShowKey] = useState(false); const [isEncrypted, setIsEncrypted] = useState(false); const apiKeyRef = useRef(null); + // Which provider the stored key belongs to. A key is only usable for the + // provider it was saved under — see handleProviderChange. + const savedKeyProvider = useRef(null); + const hasStoredKey = useRef(false); // 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. @@ -172,6 +179,15 @@ function OptionsPage() { setModelsStatus('loading'); setModelsError(''); + // Never ask the background to fall back to a key saved for another provider — + // it comes back as a bogus "Invalid API Key" from the provider being switched to. + if (!inlineKey && prov !== 'openrouter' && hasStoredKey.current && savedKeyProvider.current !== prov) { + setModels([]); + setModelsStatus('idle'); + setModelsError(`Your saved key was entered for ${providerName(savedKeyProvider.current)}. Enter a ${providerName(prov)} API key above, then click ↻ Load.`); + return; + } + const response = await safeSendMessage({ type: 'LIST_MODELS', provider: prov, apiKey: inlineKey }); if (!response) { @@ -183,6 +199,14 @@ function OptionsPage() { if (response.error) { setModels([]); setModelsStatus('error'); + // A rejected key means the stored one is unusable — drop the 🔒 badge so + // the field invites a new key instead of implying one is configured. + if (response.keyRejected && !inlineKey) { + hasStoredKey.current = false; + setIsEncrypted(false); + setModelsError(`${response.error} Enter a valid ${providerName(prov)} API key above and save again.`); + return; + } setModelsError(response.error); return; } @@ -195,7 +219,7 @@ function OptionsPage() { }; useEffect(() => { - safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => { + safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model', 'keyProvider'], (result) => { const prov = result.provider || 'openai'; if (result.provider) setProvider(result.provider); if (result.model) setModel(result.model); @@ -209,6 +233,14 @@ function OptionsPage() { setIsEncrypted(false); } + if (result.apiKeyEnc || result.apiKey) { + hasStoredKey.current = true; + // Keys saved before `keyProvider` existed: assume the stored provider. + savedKeyProvider.current = (result.keyProvider as string) || prov; + // A key belonging to another provider is not usable here — don't show 🔒. + if (savedKeyProvider.current !== prov) 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,7 +256,19 @@ function OptionsPage() { setModels([]); setModelsStatus('idle'); setModelsError(''); - loadModels(newProvider); + + // The stored key belongs to whichever provider was saved last. Listing with + // it after a switch makes the *new* provider reject it ("Invalid API Key"), + // which reads as "my key doesn't work". Ask for the new key instead. + const keyIsForThisProvider = hasStoredKey.current && savedKeyProvider.current === newProvider; + // Drives both the 🔒 badge and whether Save may keep the existing key. + setIsEncrypted(keyIsForThisProvider); + + if (apiKey.trim() || keyIsForThisProvider || newProvider === 'openrouter') { + loadModels(newProvider); + } else { + setModelsError(`Enter your ${providerName(newProvider)} API key above, then click ↻ Load.`); + } }; const handleSave = async () => { @@ -236,13 +280,18 @@ function OptionsPage() { } // If key is blank and we already have an encrypted key, don't overwrite if (!apiKey.trim() && !isEncrypted) { + setModelsError('Enter your API key before saving.'); apiKeyRef.current?.focus(); return; } + setModelsError(''); if (!apiKey.trim() && isEncrypted) { - // Only saving provider/model changes — preserve existing encrypted key + // Only saving provider/model changes — preserve existing encrypted key. + // isEncrypted is only true when that key belongs to `provider`, so + // stamping keyProvider here is safe and upgrades pre-keyProvider saves. setSaveStatus('saving'); - safeStorageSet({ provider, model }, () => { + savedKeyProvider.current = provider; + safeStorageSet({ provider, model, keyProvider: provider }, () => { if (chrome.runtime.lastError) { setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); @@ -260,13 +309,15 @@ function OptionsPage() { const key = await getOrCreateEncKey(); const apiKeyEncB64 = encryptApiKey(apiKey.trim(), key); - safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model }, () => { + safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model, keyProvider: provider }, () => { if (chrome.runtime.lastError) { setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); } else { // Remove plaintext key if it existed chrome.storage.local.remove('apiKey'); + savedKeyProvider.current = provider; + hasStoredKey.current = true; setIsEncrypted(true); setApiKey(''); setSaveStatus('saved'); @@ -419,8 +470,13 @@ function OptionsPage() { {modelsStatus === 'loading' ? '⏳' : '↻ Load'} - {modelsStatus === 'error' && modelsError && ( -
⚠ {modelsError}
+ {/* Rendered whenever set — not only on load errors. Gating this on + modelsStatus made the save-time messages invisible, so Save looked + like a no-op. */} + {modelsError && ( +
+ ⚠ {modelsError} +
)} diff --git a/package-lock.json b/package-lock.json index 46ff510..67b5cf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lexai", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lexai", - "version": "1.0.1", + "version": "1.0.2", "hasInstallScript": true, "dependencies": { "@wxt-dev/module-react": "^1.1.5", diff --git a/package.json b/package.json index c96fde9..9a906f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lexai", - "version": "1.0.1", + "version": "1.0.2", "description": "A Grammarly-like Chrome Extension powered by your own LLM provider and API key", "engines": { "node": ">=22" diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 4c83b23..e823aa0 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -167,6 +167,9 @@ export const PROVIDER_SPECS: Record = { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', + // Anthropic rejects browser-origin requests unless this opt-in is sent. + // The service worker counts as a browser origin, so it's required here too. + 'anthropic-dangerous-direct-browser-access': 'true', }), body: (model, systemPrompt, text, maxTokens) => ({ model, @@ -200,6 +203,16 @@ export const PROVIDER_SPECS: Record = { }, }; +// Display name for a provider id ('groq' → 'Groq'), for user-facing messages. +export function providerLabel(provider: string): string { + return PROVIDER_SPECS[provider]?.label ?? provider; +} + +// A rejected key is the one failure users can actually fix, and the provider's +// own wording ("Invalid API Key") doesn't say where to fix it. +const KEY_HINT = ' — open LexAI Settings and re-enter your API key for this provider.'; +const isAuthStatus = (status: number) => status === 401 || status === 403; + // ─── Chat call ──────────────────────────────────────────────────────────────── // Scale the output budget with the input instead of the old hard-coded 1024 @@ -244,7 +257,7 @@ export async function callProvider( const data = await res.json().catch(() => null); if (!res.ok) { const msg = data?.error?.message ?? `HTTP ${res.status}`; - return { error: `${spec.label} error: ${msg}` }; + return { error: `${spec.label} error: ${msg}${isAuthStatus(res.status) ? KEY_HINT : ''}` }; } const result = spec.extract(data); @@ -260,7 +273,7 @@ const NON_CHAT_MODEL_RE = /embedding|whisper|tts|dall-e|audio|realtime|moderatio export async function listModels( provider: string, apiKey?: string, -): Promise<{ models?: string[]; error?: string }> { +): Promise<{ models?: string[]; error?: string; keyRejected?: boolean }> { const spec = PROVIDER_SPECS[provider]; if (!spec) return { error: `Unknown provider: "${provider}". Please check LexAI settings.` }; @@ -280,13 +293,15 @@ export async function listModels( try { res = await fetchWithTimeout(spec.modelsUrl, { method: 'GET', headers }, 15000); } catch (err) { - return { error: `Network error reaching ${provider}: ${String(err)}` }; + return { error: `Network error reaching ${spec.label}: ${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}` }; + // Flag rejected keys so Options can tell the user to re-enter one instead + // of leaving the 🔒 badge implying the stored key is usable. + return { error: `${spec.label} error: ${msg}`, ...(isAuthStatus(res.status) ? { keyRejected: true } : {}) }; } const raw = Array.isArray(data?.data) ? data.data : []; @@ -296,6 +311,6 @@ export async function listModels( .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}.` }; + if (ids.length === 0) return { error: `No models returned by ${spec.label}.` }; return { models: ids }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 1064f5a..9f7f33a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,6 +26,11 @@ export interface LexAIConfig { apiKeyEnc?: string; // base64(nonce + secretbox ciphertext) encKey?: string; // base64 32-byte secretbox key model?: string; + // Which provider the stored key was entered for. `provider` can change on its + // own (Options saves provider/model without touching an existing key), so + // without this the key silently gets sent to a provider that will reject it. + // Absent for keys saved by builds before this field existed. + keyProvider?: string; } export interface LexAIResponse { @@ -34,7 +39,7 @@ export interface LexAIResponse { } // Storage keys the background worker reads when resolving provider config. -export const CONFIG_STORAGE_KEYS = ['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'] as const; +export const CONFIG_STORAGE_KEYS = ['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model', 'keyProvider'] as const; // ─── Message contract ───────────────────────────────────────────────────────── // ANALYZE_TEXT intentionally supports BOTH shapes: diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts index 203eb5a..d7b1647 100644 --- a/tests/unit/providers.test.ts +++ b/tests/unit/providers.test.ts @@ -4,6 +4,7 @@ import { listModels, getSystemPrompt, defaultMaxTokens, + providerLabel, PROVIDER_SPECS, } from '@lib/providers'; @@ -65,6 +66,7 @@ describe('callProvider request shapes (parity with old implementations)', () => 'Content-Type': 'application/json', 'x-api-key': 'sk-ant', 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', }); expect(JSON.parse(init.body)).toEqual({ model: 'claude-3-5-haiku-20241022', @@ -113,9 +115,10 @@ describe('callProvider request shapes (parity with old implementations)', () => describe('callProvider error handling (parity with old implementations)', () => { it('surfaces provider error messages with the provider label', async () => { - mockFetchOnce({ error: { message: 'invalid api key' } }, { ok: false, status: 401 }); + // 401/403 additionally carry the settings hint — see 'key-rejection messaging'. + mockFetchOnce({ error: { message: 'rate limited' } }, { ok: false, status: 429 }); const res = await callProvider({ provider: 'openai', apiKey: 'bad' }, 'x', 'SYS'); - expect(res).toEqual({ error: 'OpenAI error: invalid api key' }); + expect(res).toEqual({ error: 'OpenAI error: rate limited' }); }); it('falls back to HTTP status when the error body has no message', async () => { @@ -213,6 +216,26 @@ describe('defaultMaxTokens', () => { }); }); +describe('key-rejection messaging', () => { + it('appends a settings hint to 401/403 chat errors only', async () => { + mockFetchOnce({ error: { message: 'Invalid API Key' } }, { ok: false, status: 401 }); + const rejected = await callProvider({ provider: 'groq', apiKey: 'bad' }, 'hi', 'SYS'); + expect(rejected.error).toBe( + 'Groq error: Invalid API Key — open LexAI Settings and re-enter your API key for this provider.', + ); + + mockFetchOnce({ error: { message: 'server exploded' } }, { ok: false, status: 500 }); + const other = await callProvider({ provider: 'groq', apiKey: 'k' }, 'hi', 'SYS'); + expect(other.error).toBe('Groq error: server exploded'); + }); + + it('maps provider ids to display names', () => { + expect(providerLabel('groq')).toBe('Groq'); + expect(providerLabel('anthropic')).toBe('Anthropic'); + expect(providerLabel('mystery')).toBe('mystery'); + }); +}); + describe('listModels', () => { it('requires a key for Anthropic and sends the direct-browser-access header', async () => { expect(await listModels('anthropic')).toEqual({ @@ -250,9 +273,31 @@ describe('listModels', () => { expect(await listModels('openai', 'k')).toEqual({ models: ['gpt-4o', 'gpt-4o-mini'] }); }); + it('sends bearer auth for Groq and labels its errors with the display name', async () => { + const fetch = mockFetchOnce({ data: [{ id: 'llama-3.3-70b-versatile' }] }); + expect(await listModels('groq', 'gsk_test')).toEqual({ models: ['llama-3.3-70b-versatile'] }); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe('https://api.groq.com/openai/v1/models'); + expect(init.headers['Authorization']).toBe('Bearer gsk_test'); + + mockFetchOnce({ error: { message: 'Invalid API Key' } }, { ok: false, status: 401 }); + expect((await listModels('groq', 'bad')).error).toBe('Groq error: Invalid API Key'); + }); + + it('flags a rejected key so Options can prompt for a new one', async () => { + mockFetchOnce({ error: { message: 'Invalid API Key' } }, { ok: false, status: 401 }); + expect(await listModels('groq', 'bad')).toEqual({ + error: 'Groq error: Invalid API Key', + keyRejected: true, + }); + + mockFetchOnce({ error: { message: 'boom' } }, { ok: false, status: 500 }); + expect((await listModels('groq', 'k')).keyRejected).toBeUndefined(); + }); + it('errors on empty lists and unknown providers', async () => { mockFetchOnce({ data: [] }); - expect((await listModels('openai', 'k')).error).toBe('No models returned by openai.'); + expect((await listModels('openai', 'k')).error).toBe('No models returned by OpenAI.'); expect((await listModels('bogus', 'k')).error).toContain('Unknown provider'); }); });