refactor: collapse 8 provider functions into one adapter table

src/lib/providers.ts holds a per-provider spec (url, headers, body,
extractor, default model) and a single callProvider() covering both the
action-prompt and custom-prompt families (~250 lines -> one path).
getSystemPrompt, fetchWithTimeout, and listModels move along with it;
background.ts drops from 553 to 130 lines.

Behavior pinned by 20 parity tests written from the old functions
(request shapes, headers, defaults, error strings). One deliberate
change: max_tokens now scales with input length (1024-8192) instead of
a hard-coded 1024, so Expand no longer truncates long selections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
john kevin asprec
2026-07-14 21:42:39 +08:00
parent c4634d4965
commit f0962471b4
3 changed files with 453 additions and 385 deletions

View File

@@ -2,52 +2,7 @@ import { defineBackground } from 'wxt/utils/define-background';
import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types';
import { ACTIONS, ACTION_LABELS, CONTEXT_MENU_STYLES } from '@lib/actions';
import { decryptApiKey } from '@lib/crypto';
// ─── Fetch with timeout ───────────────────────────────────────────────────────
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 30000): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(id);
}
}
// ─── System prompts ───────────────────────────────────────────────────────────
function getSystemPrompt(action: string, style?: string): string {
// Normalize 'fix' (used by context menu) to 'grammar'
const normalizedAction = action === 'fix' ? 'grammar' : action;
const prompts: Record<string, string> = {
grammar:
'You are a professional grammar editor. Fix all grammar, spelling, and punctuation errors in the provided text. ' +
'Preserve the original meaning and tone as closely as possible. ' +
'Return ONLY the corrected text — no explanations, no preamble.',
rephrase:
'You are a skilled writing assistant. Rephrase the provided text to make it clearer, more engaging, and more professional. ' +
'Keep the same meaning and approximate length. ' +
'Return ONLY the rephrased text — no explanations.',
shorten:
'You are a concise editor. Shorten the provided text by at least 30% while preserving the core message. ' +
'Remove filler words, redundant phrases, and unnecessary detail. ' +
'Return ONLY the shortened text.',
expand:
'You are an experienced writer. Expand the provided text with more detail, context, and supporting points. ' +
'Make it richer and more informative while staying on topic. ' +
'Return ONLY the expanded text.',
explain:
'You are a helpful teacher. Explain the following text in simple, easy-to-understand language. ' +
'Break down complex terms, jargon, or concepts so anyone can understand. ' +
'Be concise but clear. Return only the explanation, no extra commentary.',
};
const base = prompts[normalizedAction] ?? prompts.grammar;
const styleModifier = style && style !== 'Default'
? ` Write in a ${style.toLowerCase()} style.`
: '';
return base + styleModifier;
}
import { callProvider, getSystemPrompt, listModels } from '@lib/providers';
// 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.
@@ -61,331 +16,6 @@ async function resolveApiKey(config: LexAIConfig): Promise<string | null> {
return apiKey.trim();
}
// ─── Provider implementations ─────────────────────────────────────────────────
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const model = config.model || 'gpt-4o-mini';
let res: Response;
try {
res = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: getSystemPrompt(payload.action, payload.style) },
{ role: 'user', content: payload.text },
],
max_tokens: 1024,
temperature: 0.7,
}),
});
} catch (err) {
return { error: `Network error reaching OpenAI: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `OpenAI error: ${msg}` };
}
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'OpenAI returned an empty response.' };
return { result: result.trim() };
}
async function callAnthropic(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const model = config.model || 'claude-3-5-haiku-20241022';
let res: Response;
try {
res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': config.apiKey!,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model,
max_tokens: 1024,
system: getSystemPrompt(payload.action, payload.style),
messages: [{ role: 'user', content: payload.text }],
}),
});
} catch (err) {
return { error: `Network error reaching Anthropic: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `Anthropic error: ${msg}` };
}
const result = data?.content?.[0]?.text as string | undefined;
if (!result) return { error: 'Anthropic returned an empty response.' };
return { result: result.trim() };
}
async function callGroq(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const model = config.model || 'llama-3.3-70b-versatile';
let res: Response;
try {
res = await fetchWithTimeout('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: getSystemPrompt(payload.action, payload.style) },
{ role: 'user', content: payload.text },
],
max_tokens: 1024,
temperature: 0.7,
}),
});
} catch (err) {
return { error: `Network error reaching Groq: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `Groq error: ${msg}` };
}
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'Groq returned an empty response.' };
return { result: result.trim() };
}
async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
const model = config.model || 'openai/gpt-4o-mini';
let res: Response;
try {
res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
'HTTP-Referer': 'https://lexai.dev',
'X-Title': 'LexAI',
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: getSystemPrompt(payload.action, payload.style) },
{ role: 'user', content: payload.text },
],
max_tokens: 1024,
}),
});
} catch (err) {
return { error: `Network error reaching OpenRouter: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) {
const msg = data?.error?.message ?? `HTTP ${res.status}`;
return { error: `OpenRouter error: ${msg}` };
}
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'OpenRouter returned an empty response.' };
return { result: result.trim() };
}
// ─── Generic provider call (used by copy-as and future features) ──────────────
async function callProvider(config: LexAIConfig, text: string, systemPrompt: string): Promise<LexAIResponse> {
const payload: AnalyzePayload = { text, action: '__custom__' };
const provider = config.provider || 'openai';
switch (provider) {
case 'openai': return callOpenAIWithPrompt(payload, config, systemPrompt);
case 'anthropic': return callAnthropicWithPrompt(payload, config, systemPrompt);
case 'groq': return callGroqWithPrompt(payload, config, systemPrompt);
case 'openrouter': return callOpenRouterWithPrompt(payload, config, systemPrompt);
default: return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
}
}
async function callOpenAIWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
const model = config.model || 'gpt-4o-mini';
let res: Response;
try {
res = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: payload.text },
],
max_tokens: 1024,
temperature: 0.7,
}),
});
} catch (err) {
return { error: `Network error reaching OpenAI: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) return { error: `OpenAI error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'OpenAI returned an empty response.' };
return { result: result.trim() };
}
async function callAnthropicWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
const model = config.model || 'claude-3-5-haiku-20241022';
let res: Response;
try {
res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': config.apiKey!, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model,
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: 'user', content: payload.text }],
}),
});
} catch (err) {
return { error: `Network error reaching Anthropic: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) return { error: `Anthropic error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
const result = data?.content?.[0]?.text as string | undefined;
if (!result) return { error: 'Anthropic returned an empty response.' };
return { result: result.trim() };
}
async function callGroqWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
const model = config.model || 'llama-3.3-70b-versatile';
let res: Response;
try {
res = await fetchWithTimeout('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: payload.text },
],
max_tokens: 1024,
temperature: 0.7,
}),
});
} catch (err) {
return { error: `Network error reaching Groq: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) return { error: `Groq error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'Groq returned an empty response.' };
return { result: result.trim() };
}
async function callOpenRouterWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise<LexAIResponse> {
const model = config.model || 'openai/gpt-4o-mini';
let res: Response;
try {
res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
'HTTP-Referer': 'https://lexai.dev',
'X-Title': 'LexAI',
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: payload.text },
],
max_tokens: 1024,
}),
});
} catch (err) {
return { error: `Network error reaching OpenRouter: ${String(err)}` };
}
const data = await res.json();
if (!res.ok) return { error: `OpenRouter error: ${data?.error?.message ?? `HTTP ${res.status}`}` };
const result = data?.choices?.[0]?.message?.content as string | undefined;
if (!result) return { error: 'OpenRouter returned an empty response.' };
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> {
@@ -398,20 +28,8 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse
}
const resolvedConfig: LexAIConfig = { ...config, apiKey };
const provider = config.provider || 'openai';
switch (provider) {
case 'openai':
return callOpenAI(payload, resolvedConfig);
case 'anthropic':
return callAnthropic(payload, resolvedConfig);
case 'groq':
return callGroq(payload, resolvedConfig);
case 'openrouter':
return callOpenRouter(payload, resolvedConfig);
default:
return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
}
const systemPrompt = getSystemPrompt(payload.action, payload.style);
return callProvider(resolvedConfig, payload.text, systemPrompt);
}
// ─── Background entry ─────────────────────────────────────────────────────────