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

@@ -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 */}