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:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
237
src/lib/providers.ts
Normal file
237
src/lib/providers.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// Provider adapter table + the single LLM call path. Replaces the eight
|
||||
// hand-rolled per-provider functions (callX / callXWithPrompt families) that
|
||||
// previously lived in the background worker. Behavior-preserving: URLs,
|
||||
// headers, request bodies, default models, and error strings match the old
|
||||
// implementations — pinned by tests/unit/providers.test.ts.
|
||||
|
||||
import type { LexAIConfig, LexAIResponse } from './types';
|
||||
|
||||
export 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
export function getSystemPrompt(action: string, style?: string): string {
|
||||
// Normalize 'fix' (used by context menu and popup) 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;
|
||||
}
|
||||
|
||||
// ─── Adapter table ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProviderSpec {
|
||||
label: string; // human name used in error messages ('OpenAI error: …')
|
||||
chatUrl: string;
|
||||
modelsUrl: string;
|
||||
defaultModel: string;
|
||||
headers: (apiKey: string) => Record<string, string>;
|
||||
body: (model: string, systemPrompt: string, text: string, maxTokens: number) => Record<string, unknown>;
|
||||
extract: (data: any) => string | undefined;
|
||||
}
|
||||
|
||||
function bearerHeaders(apiKey: string): Record<string, string> {
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` };
|
||||
}
|
||||
|
||||
// OpenAI-compatible chat body (OpenAI, Groq, OpenRouter). OpenRouter's old
|
||||
// implementation sent no temperature — preserve that.
|
||||
function openAiStyleBody(temperature?: number) {
|
||||
return (model: string, systemPrompt: string, text: string, maxTokens: number) => ({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
max_tokens: maxTokens,
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const extractOpenAiStyle = (data: any): string | undefined => data?.choices?.[0]?.message?.content;
|
||||
|
||||
export const PROVIDER_SPECS: Record<string, ProviderSpec> = {
|
||||
openai: {
|
||||
label: 'OpenAI',
|
||||
chatUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
modelsUrl: 'https://api.openai.com/v1/models',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
headers: bearerHeaders,
|
||||
body: openAiStyleBody(0.7),
|
||||
extract: extractOpenAiStyle,
|
||||
},
|
||||
anthropic: {
|
||||
label: 'Anthropic',
|
||||
chatUrl: 'https://api.anthropic.com/v1/messages',
|
||||
modelsUrl: 'https://api.anthropic.com/v1/models',
|
||||
defaultModel: 'claude-3-5-haiku-20241022',
|
||||
headers: (apiKey) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
body: (model, systemPrompt, text, maxTokens) => ({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: text }],
|
||||
}),
|
||||
extract: (data) => data?.content?.[0]?.text,
|
||||
},
|
||||
groq: {
|
||||
label: 'Groq',
|
||||
chatUrl: 'https://api.groq.com/openai/v1/chat/completions',
|
||||
modelsUrl: 'https://api.groq.com/openai/v1/models',
|
||||
defaultModel: 'llama-3.3-70b-versatile',
|
||||
headers: bearerHeaders,
|
||||
body: openAiStyleBody(0.7),
|
||||
extract: extractOpenAiStyle,
|
||||
},
|
||||
openrouter: {
|
||||
label: 'OpenRouter',
|
||||
chatUrl: 'https://openrouter.ai/api/v1/chat/completions',
|
||||
modelsUrl: 'https://openrouter.ai/api/v1/models',
|
||||
defaultModel: 'openai/gpt-4o-mini',
|
||||
headers: (apiKey) => ({
|
||||
...bearerHeaders(apiKey),
|
||||
'HTTP-Referer': 'https://lexai.dev',
|
||||
'X-Title': 'LexAI',
|
||||
}),
|
||||
body: openAiStyleBody(undefined),
|
||||
extract: extractOpenAiStyle,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Chat call ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Scale the output budget with the input instead of the old hard-coded 1024
|
||||
// (which truncated "Expand" on long selections). chars ≈ tokens × 4, so this
|
||||
// allows roughly 4× the input length in output, clamped to a sane range.
|
||||
export function defaultMaxTokens(text: string): number {
|
||||
return Math.max(1024, Math.min(8192, Math.ceil(text.length)));
|
||||
}
|
||||
|
||||
export interface CallOptions {
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export async function callProvider(
|
||||
config: LexAIConfig,
|
||||
text: string,
|
||||
systemPrompt: string,
|
||||
opts?: CallOptions,
|
||||
): Promise<LexAIResponse> {
|
||||
const provider = config.provider || 'openai';
|
||||
const spec = PROVIDER_SPECS[provider];
|
||||
if (!spec) {
|
||||
return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
|
||||
}
|
||||
|
||||
const model = config.model || spec.defaultModel;
|
||||
const maxTokens = opts?.maxTokens ?? defaultMaxTokens(text);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchWithTimeout(spec.chatUrl, {
|
||||
method: 'POST',
|
||||
headers: spec.headers(config.apiKey ?? ''),
|
||||
body: JSON.stringify(spec.body(model, systemPrompt, text, maxTokens)),
|
||||
});
|
||||
} catch (err) {
|
||||
return { error: `Network error reaching ${spec.label}: ${String(err)}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const msg = data?.error?.message ?? `HTTP ${res.status}`;
|
||||
return { error: `${spec.label} error: ${msg}` };
|
||||
}
|
||||
|
||||
const result = spec.extract(data);
|
||||
if (!result) return { error: `${spec.label} returned an empty response.` };
|
||||
return { result: result.trim() };
|
||||
}
|
||||
|
||||
// ─── Live model listing ───────────────────────────────────────────────────────
|
||||
|
||||
// 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;
|
||||
|
||||
export async function listModels(
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
): Promise<{ models?: string[]; error?: string }> {
|
||||
const spec = PROVIDER_SPECS[provider];
|
||||
if (!spec) 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(spec.modelsUrl, { 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 };
|
||||
}
|
||||
213
tests/unit/providers.test.ts
Normal file
213
tests/unit/providers.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
callProvider,
|
||||
listModels,
|
||||
getSystemPrompt,
|
||||
defaultMaxTokens,
|
||||
PROVIDER_SPECS,
|
||||
} from '@lib/providers';
|
||||
|
||||
function mockFetchOnce(data: unknown, { ok = true, status = 200 } = {}) {
|
||||
const fn = vi.fn().mockResolvedValue({ ok, status, json: async () => data });
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
// ─── Request shapes pinned to the ORIGINAL callX/callXWithPrompt functions ────
|
||||
// These bodies/headers/URLs are copied from the pre-refactor background.ts.
|
||||
// maxTokens is forced to 1024 to match the old hard-coded value exactly.
|
||||
|
||||
const openAiResponse = { choices: [{ message: { content: ' fixed text ' } }] };
|
||||
const anthropicResponse = { content: [{ text: ' fixed text ' }] };
|
||||
|
||||
describe('callProvider request shapes (parity with old implementations)', () => {
|
||||
it('OpenAI: url, bearer auth, system message, temperature 0.7, default model', async () => {
|
||||
const fetch = mockFetchOnce(openAiResponse);
|
||||
const res = await callProvider({ provider: 'openai', apiKey: 'sk-x' }, 'hello', 'SYS', { maxTokens: 1024 });
|
||||
|
||||
expect(res).toEqual({ result: 'fixed text' });
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://api.openai.com/v1/chat/completions');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json', Authorization: 'Bearer sk-x' });
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: 'SYS' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('Anthropic: x-api-key + version headers, top-level system, no temperature', async () => {
|
||||
const fetch = mockFetchOnce(anthropicResponse);
|
||||
const res = await callProvider({ provider: 'anthropic', apiKey: 'sk-ant' }, 'hello', 'SYS', { maxTokens: 1024 });
|
||||
|
||||
expect(res).toEqual({ result: 'fixed text' });
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages');
|
||||
expect(init.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'sk-ant',
|
||||
'anthropic-version': '2023-06-01',
|
||||
});
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 1024,
|
||||
system: 'SYS',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('Groq: OpenAI-compatible endpoint and body with temperature', async () => {
|
||||
const fetch = mockFetchOnce(openAiResponse);
|
||||
await callProvider({ provider: 'groq', apiKey: 'gsk-x' }, 'hello', 'SYS', { maxTokens: 1024 });
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://api.groq.com/openai/v1/chat/completions');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json', Authorization: 'Bearer gsk-x' });
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.model).toBe('llama-3.3-70b-versatile');
|
||||
expect(body.temperature).toBe(0.7);
|
||||
expect(body.max_tokens).toBe(1024);
|
||||
});
|
||||
|
||||
it('OpenRouter: referer/title headers, NO temperature', async () => {
|
||||
const fetch = mockFetchOnce(openAiResponse);
|
||||
await callProvider({ provider: 'openrouter', apiKey: 'sk-or' }, 'hello', 'SYS', { maxTokens: 1024 });
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://openrouter.ai/api/v1/chat/completions');
|
||||
expect(init.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer sk-or',
|
||||
'HTTP-Referer': 'https://lexai.dev',
|
||||
'X-Title': 'LexAI',
|
||||
});
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.model).toBe('openai/gpt-4o-mini');
|
||||
expect(body).not.toHaveProperty('temperature');
|
||||
});
|
||||
|
||||
it('uses the configured model over the default', async () => {
|
||||
const fetch = mockFetchOnce(openAiResponse);
|
||||
await callProvider({ provider: 'openai', apiKey: 'k', model: 'gpt-4o' }, 'x', 'SYS');
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body).model).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callProvider error handling (parity with old implementations)', () => {
|
||||
it('surfaces provider error messages with the provider label', async () => {
|
||||
mockFetchOnce({ error: { message: 'invalid api key' } }, { ok: false, status: 401 });
|
||||
const res = await callProvider({ provider: 'openai', apiKey: 'bad' }, 'x', 'SYS');
|
||||
expect(res).toEqual({ error: 'OpenAI error: invalid api key' });
|
||||
});
|
||||
|
||||
it('falls back to HTTP status when the error body has no message', async () => {
|
||||
mockFetchOnce({}, { ok: false, status: 500 });
|
||||
const res = await callProvider({ provider: 'groq', apiKey: 'k' }, 'x', 'SYS');
|
||||
expect(res).toEqual({ error: 'Groq error: HTTP 500' });
|
||||
});
|
||||
|
||||
it('reports network failures with the provider label', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
const res = await callProvider({ provider: 'anthropic', apiKey: 'k' }, 'x', 'SYS');
|
||||
expect(res.error).toMatch(/^Network error reaching Anthropic:/);
|
||||
});
|
||||
|
||||
it('reports empty responses', async () => {
|
||||
mockFetchOnce({ choices: [] });
|
||||
const res = await callProvider({ provider: 'openrouter', apiKey: 'k' }, 'x', 'SYS');
|
||||
expect(res).toEqual({ error: 'OpenRouter returned an empty response.' });
|
||||
});
|
||||
|
||||
it('rejects unknown providers without fetching', async () => {
|
||||
const fetch = mockFetchOnce({});
|
||||
const res = await callProvider({ provider: 'bogus', apiKey: 'k' }, 'x', 'SYS');
|
||||
expect(res.error).toContain('Unknown provider: "bogus"');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to openai when no provider is configured', async () => {
|
||||
const fetch = mockFetchOnce(openAiResponse);
|
||||
await callProvider({ apiKey: 'k' }, 'x', 'SYS');
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://api.openai.com/v1/chat/completions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemPrompt', () => {
|
||||
it("normalizes 'fix' to the grammar prompt", () => {
|
||||
expect(getSystemPrompt('fix')).toBe(getSystemPrompt('grammar'));
|
||||
expect(getSystemPrompt('fix')).toContain('grammar editor');
|
||||
});
|
||||
|
||||
it('falls back to grammar for unknown actions', () => {
|
||||
expect(getSystemPrompt('nonsense')).toBe(getSystemPrompt('grammar'));
|
||||
});
|
||||
|
||||
it('appends a style modifier except for Default', () => {
|
||||
expect(getSystemPrompt('rephrase', 'Formal')).toMatch(/Write in a formal style\.$/);
|
||||
expect(getSystemPrompt('rephrase', 'Default')).not.toContain('style.');
|
||||
expect(getSystemPrompt('rephrase')).not.toContain('Write in a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultMaxTokens', () => {
|
||||
it('never goes below the old 1024 budget', () => {
|
||||
expect(defaultMaxTokens('short')).toBe(1024);
|
||||
});
|
||||
|
||||
it('scales with input length and clamps at 8192', () => {
|
||||
expect(defaultMaxTokens('a'.repeat(4000))).toBe(4000);
|
||||
expect(defaultMaxTokens('a'.repeat(50000))).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels', () => {
|
||||
it('requires a key for Anthropic and sends the direct-browser-access header', async () => {
|
||||
expect(await listModels('anthropic')).toEqual({
|
||||
error: 'Anthropic requires an API key to list models.',
|
||||
});
|
||||
|
||||
const fetch = mockFetchOnce({ data: [{ id: 'claude-3-5-haiku-20241022' }] });
|
||||
await listModels('anthropic', 'sk-ant');
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://api.anthropic.com/v1/models');
|
||||
expect(init.headers['anthropic-dangerous-direct-browser-access']).toBe('true');
|
||||
expect(init.headers['x-api-key']).toBe('sk-ant');
|
||||
});
|
||||
|
||||
it('allows keyless listing (OpenRouter) and sends bearer auth when a key exists', async () => {
|
||||
const noKey = mockFetchOnce({ data: [{ id: 'openai/gpt-4o' }] });
|
||||
await listModels('openrouter');
|
||||
expect(noKey.mock.calls[0][1].headers).not.toHaveProperty('Authorization');
|
||||
|
||||
const withKey = mockFetchOnce({ data: [{ id: 'gpt-4o' }] });
|
||||
await listModels('openai', 'sk-x');
|
||||
expect(withKey.mock.calls[0][1].headers['Authorization']).toBe('Bearer sk-x');
|
||||
});
|
||||
|
||||
it('filters non-chat models and sorts ids', async () => {
|
||||
mockFetchOnce({
|
||||
data: [
|
||||
{ id: 'gpt-4o' },
|
||||
{ id: 'text-embedding-3-small' },
|
||||
{ id: 'whisper-1' },
|
||||
{ id: 'dall-e-3' },
|
||||
{ id: 'gpt-4o-mini' },
|
||||
],
|
||||
});
|
||||
expect(await listModels('openai', 'k')).toEqual({ models: ['gpt-4o', 'gpt-4o-mini'] });
|
||||
});
|
||||
|
||||
it('errors on empty lists and unknown providers', async () => {
|
||||
mockFetchOnce({ data: [] });
|
||||
expect((await listModels('openai', 'k')).error).toBe('No models returned by openai.');
|
||||
expect((await listModels('bogus', 'k')).error).toContain('Unknown provider');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user