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

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