feat: implement API key resolution and live model listing for providers
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 59s
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 59s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user