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

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:
john kevin asprec
2026-07-23 15:50:07 +08:00
parent d5a2f4a0be
commit 6aee260533
9 changed files with 176 additions and 45 deletions

View File

@@ -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') {

View File

@@ -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('');
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'}
</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>