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:
@@ -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