From f0962471b47031efed34efd98109f9abb888013e Mon Sep 17 00:00:00 2001 From: john kevin asprec Date: Tue, 14 Jul 2026 21:42:39 +0800 Subject: [PATCH] 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 --- entrypoints/background.ts | 388 +---------------------------------- src/lib/providers.ts | 237 +++++++++++++++++++++ tests/unit/providers.test.ts | 213 +++++++++++++++++++ 3 files changed, 453 insertions(+), 385 deletions(-) create mode 100644 src/lib/providers.ts create mode 100644 tests/unit/providers.test.ts diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 2043dd5..683c16e 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -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 { - 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 = { - 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 { return apiKey.trim(); } -// ─── Provider implementations ───────────────────────────────────────────────── - -async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 = { - 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 = { '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 { @@ -398,20 +28,8 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise { + 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 = { + 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; + body: (model: string, systemPrompt: string, text: string, maxTokens: number) => Record; + extract: (data: any) => string | undefined; +} + +function bearerHeaders(apiKey: string): Record { + 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 = { + 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 { + 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 = { '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 }; +} diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts new file mode 100644 index 0000000..1f139ed --- /dev/null +++ b/tests/unit/providers.test.ts @@ -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'); + }); +});