fix: never send an API key to the provider it wasn't entered for (v1.0.2)
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 1m33s
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 1m33s
A stored key carried no record of which provider it belonged to. Options
saves {provider, model} without the key whenever the field is blank (which
it always is after a save, since it shows the encrypted badge instead), so
switching provider left the previous provider's key attached to the new one.
Every call then failed with that provider's own "Invalid API Key" while the
UI still showed a key as configured.
- types.ts: new `keyProvider` storage field, added to CONFIG_STORAGE_KEYS
- background.ts: keyProviderMismatch() guards the chat, COPY_AS and
stored-key LIST_MODELS paths; absent keyProvider (pre-upgrade) is allowed
- Options.tsx: stamps keyProvider on every save; drops the encrypted badge
and requires a new key when the saved one belongs to another provider or
is rejected; save-time guard messages are now actually rendered (they were
gated on modelsStatus === 'error' and never drew, so Save looked dead)
- providers.ts: providerLabel(); settings hint appended to 401/403 only;
listModels reports keyRejected and labels errors with the display name
- Anthropic: send anthropic-dangerous-direct-browser-access on the chat path
Docs: CLAUDE.md version-bump rule corrected — wxt.config.ts reads
pkg.version, so package.json is the only place to edit.
typecheck clean, 58/58 tests, build clean (281.72 kB, manifest 1.0.2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <x.y.z> --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
|
||||
|
||||
@@ -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 `"<Provider> error: HTTP <status>"` 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. `<all_urls>` 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.
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<LexAIResponse> {
|
||||
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<LexAIResponse
|
||||
return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' };
|
||||
}
|
||||
|
||||
const mismatch = keyProviderMismatch(config, config.provider || 'openai');
|
||||
if (mismatch) return { error: mismatch };
|
||||
|
||||
const resolvedConfig: LexAIConfig = {
|
||||
...config,
|
||||
apiKey,
|
||||
@@ -113,7 +126,7 @@ export default defineBackground(() => {
|
||||
|
||||
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') {
|
||||
|
||||
@@ -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<HTMLInputElement>(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<string | null>(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('');
|
||||
|
||||
// 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'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsStatus === 'error' && modelsError && (
|
||||
<div style={{ ...styles.hint, color: '#f38ba8' }}>⚠ {modelsError}</div>
|
||||
{/* 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 && (
|
||||
<div style={{ ...styles.hint, color: modelsStatus === 'error' ? '#f38ba8' : '#f9e2af' }}>
|
||||
⚠ {modelsError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -167,6 +167,9 @@ export const PROVIDER_SPECS: Record<string, ProviderSpec> = {
|
||||
'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<string, ProviderSpec> = {
|
||||
},
|
||||
};
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user