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