UNPKG

eslint-doc-generator

Version:

Automatic documentation generator for ESLint plugins and rules.

347 lines (346 loc) 11.9 kB
import { AI_PROVIDER } from './types.js'; const PROVIDER_METADATA = { [AI_PROVIDER.ANTHROPIC]: { apiKeyEnvVar: 'ANTHROPIC_API_KEY', defaultModel: 'claude-sonnet-4-6', endpoint: 'https://api.anthropic.com/v1/messages', protocol: 'anthropic', }, [AI_PROVIDER.GROQ]: { apiKeyEnvVar: 'GROQ_API_KEY', defaultModel: 'llama-3.3-70b-versatile', endpoint: 'https://api.groq.com/openai/v1/chat/completions', protocol: 'openaiCompatible', }, [AI_PROVIDER.OPENAI]: { apiKeyEnvVar: 'OPENAI_API_KEY', defaultModel: 'gpt-5.2', endpoint: 'https://api.openai.com/v1/chat/completions', protocol: 'openaiCompatible', }, [AI_PROVIDER.OPENROUTER]: { apiKeyEnvVar: 'OPENROUTER_API_KEY', defaultModel: 'openai/gpt-5.2', endpoint: 'https://openrouter.ai/api/v1/chat/completions', protocol: 'openaiCompatible', }, [AI_PROVIDER.TOGETHER]: { apiKeyEnvVar: 'TOGETHER_API_KEY', defaultModel: 'openai/gpt-oss-20b', endpoint: 'https://api.together.xyz/v1/chat/completions', protocol: 'openaiCompatible', }, [AI_PROVIDER.VERCEL_AI_GATEWAY]: { apiKeyEnvVar: 'VERCEL_AI_GATEWAY_API_KEY', defaultModel: 'openai/gpt-5.2', endpoint: 'https://ai-gateway.vercel.sh/v1/chat/completions', protocol: 'openaiCompatible', }, [AI_PROVIDER.XAI]: { apiKeyEnvVar: 'XAI_API_KEY', defaultModel: 'grok-4-1-fast-reasoning', endpoint: 'https://api.x.ai/v1/chat/completions', protocol: 'openaiCompatible', }, }; const AI_PROVIDERS = Object.values(AI_PROVIDER); export const SUPPORTED_API_KEY_ENV_VARS = [ ...new Set(AI_PROVIDERS.map((provider) => PROVIDER_METADATA[provider].apiKeyEnvVar)), ]; const REQUEST_TIMEOUT_MS = 30_000; function getProviderLabel(provider) { return { [AI_PROVIDER.ANTHROPIC]: 'Anthropic', [AI_PROVIDER.GROQ]: 'Groq', [AI_PROVIDER.OPENAI]: 'OpenAI', [AI_PROVIDER.OPENROUTER]: 'OpenRouter', [AI_PROVIDER.TOGETHER]: 'Together', [AI_PROVIDER.VERCEL_AI_GATEWAY]: 'Vercel AI Gateway', [AI_PROVIDER.XAI]: 'xAI', }[provider]; } function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function getOptionalEnvVar(name) { const value = process.env[name]; if (!value || value === 'undefined') { return undefined; } return value; } export function resolveAiProviderConfig({ aiProvider, aiModel, }) { if (aiProvider) { const metadata = PROVIDER_METADATA[aiProvider]; const apiKey = getOptionalEnvVar(metadata.apiKeyEnvVar); if (!apiKey) { throw new Error(`Provider "${aiProvider}" requires ${metadata.apiKeyEnvVar} to be set.`); } return { provider: aiProvider, apiKey, model: aiModel ?? metadata.defaultModel, endpoint: metadata.endpoint, protocol: metadata.protocol, }; } const providersWithApiKey = AI_PROVIDERS.flatMap((provider) => { const apiKey = getOptionalEnvVar(PROVIDER_METADATA[provider].apiKeyEnvVar); return apiKey ? [{ provider, apiKey }] : []; }); if (providersWithApiKey.length === 0) { throw new Error(`No AI provider API key found. Set one of: ${SUPPORTED_API_KEY_ENV_VARS.join(', ')}.`); } if (providersWithApiKey.length > 1) { throw new Error(`Multiple AI provider API keys found (${providersWithApiKey .map(({ provider }) => PROVIDER_METADATA[provider].apiKeyEnvVar) .join(', ')}). Use --ai-provider to specify one.`); } const { provider, apiKey } = providersWithApiKey[0]; const metadata = PROVIDER_METADATA[provider]; return { provider, apiKey, model: aiModel ?? metadata.defaultModel, endpoint: metadata.endpoint, protocol: metadata.protocol, }; } function getOpenAiContent(payload) { if (!isRecord(payload)) { return undefined; } const choicesUnknown = payload['choices']; if (!Array.isArray(choicesUnknown) || choicesUnknown.length === 0) { return undefined; } const [firstChoice] = choicesUnknown; if (!isRecord(firstChoice)) { return undefined; } const message = firstChoice['message']; if (!isRecord(message)) { return undefined; } const content = message['content']; if (typeof content === 'string') { return content; } if (!Array.isArray(content)) { return undefined; } const textParts = content.flatMap((part) => { if (!isRecord(part)) { return []; } const text = part['text']; return typeof text === 'string' ? [text] : []; }); return textParts.length > 0 ? textParts.join('') : undefined; } function getAnthropicContent(payload) { if (!isRecord(payload)) { return undefined; } const content = payload['content']; if (!Array.isArray(content)) { return undefined; } const textParts = content.flatMap((part) => { if (!isRecord(part)) { return []; } if (part['type'] !== 'text') { return []; } const text = part['text']; return typeof text === 'string' ? [text] : []; }); return textParts.length > 0 ? textParts.join('') : undefined; } function getProviderErrorDetails(detailsUnknown) { if (!isRecord(detailsUnknown)) { return undefined; } const codeUnknown = detailsUnknown['code']; const typeUnknown = detailsUnknown['type']; const messageUnknown = detailsUnknown['message']; const code = typeof codeUnknown === 'string' ? codeUnknown : typeof codeUnknown === 'number' ? String(codeUnknown) : undefined; const type = typeof typeUnknown === 'string' ? typeUnknown : undefined; const message = typeof messageUnknown === 'string' ? messageUnknown : undefined; if (!code && !type && !message) { return undefined; } return { ...(code ? { code } : {}), ...(type ? { type } : {}), ...(message ? { message } : {}), }; } function getProtocolErrorDetails(payload) { if (!isRecord(payload)) { return undefined; } return (getProviderErrorDetails(payload['error']) ?? getProviderErrorDetails(payload)); } function formatProviderErrorDetails(details) { if (!details) { return undefined; } const segments = []; if (details.message) { segments.push(details.message); } if (details.code) { segments.push(`code: ${details.code}`); } if (details.type) { segments.push(`type: ${details.type}`); } return segments.length > 0 ? segments.join('; ') : undefined; } async function getHttpErrorDetails(response) { let payload; try { payload = await response.json(); } catch { return undefined; } return getProtocolErrorDetails(payload); } async function throwProviderHttpError(providerConfig, response) { const providerLabel = getProviderLabel(providerConfig.provider); const details = await getHttpErrorDetails(response); const extraText = formatProviderErrorDetails(details); throw new Error(extraText ? `${providerLabel} request failed (${String(response.status)} ${response.statusText}). ${extraText}` : `${providerLabel} request failed (${String(response.status)} ${response.statusText}).`); } function parseLlmResponseObject(content) { const trimmed = content.trim(); if (!trimmed) { throw new Error('AI response was empty.'); } const withoutFences = trimmed .replaceAll(/^```(?:json)?\s*/giu, '') .replaceAll(/\s*```$/gu, ''); const firstBrace = withoutFences.indexOf('{'); const lastBrace = withoutFences.lastIndexOf('}'); const jsonLike = firstBrace !== -1 && lastBrace > firstBrace ? withoutFences.slice(firstBrace, lastBrace + 1) : withoutFences; const parsed = JSON.parse(jsonLike); if (!isRecord(parsed)) { throw new Error('AI response was not a JSON object.'); } return parsed; } function buildOpenAiCompatibleRequest(providerConfig, prompt) { const responseFormatType = providerConfig.provider === AI_PROVIDER.VERCEL_AI_GATEWAY ? 'json' : 'json_object'; const requestBody = { model: providerConfig.model, temperature: 0, messages: [ ...(prompt.systemPrompt ? [{ role: 'system', content: prompt.systemPrompt }] : []), { role: 'user', content: prompt.userPrompt, }, ], response_format: { type: responseFormatType, }, }; return { headers: { Authorization: `Bearer ${providerConfig.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), getContent: getOpenAiContent, }; } function buildAnthropicRequest(providerConfig, prompt) { const requestBody = { model: providerConfig.model, max_tokens: 512, temperature: 0, messages: [ { role: 'user', content: prompt.userPrompt, }, ], }; if (prompt.systemPrompt) { requestBody.system = prompt.systemPrompt; } return { headers: { 'x-api-key': providerConfig.apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', }, body: JSON.stringify(requestBody), getContent: getAnthropicContent, }; } function buildProtocolRequest(providerConfig, prompt) { return providerConfig.protocol === 'openaiCompatible' ? buildOpenAiCompatibleRequest(providerConfig, prompt) : buildAnthropicRequest(providerConfig, prompt); } async function requestProviderObject(providerConfig, prompt) { const providerLabel = getProviderLabel(providerConfig.provider); const { headers, body, getContent } = buildProtocolRequest(providerConfig, prompt); const abortController = new AbortController(); const timeoutId = setTimeout(() => { abortController.abort(); }, REQUEST_TIMEOUT_MS); let response; try { // eslint-disable-next-line n/no-unsupported-features/node-builtins response = await fetch(providerConfig.endpoint, { method: 'POST', headers, body, signal: abortController.signal, }); } catch (error) { const isAbortError = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); if (abortController.signal.aborted || isAbortError) { throw new Error(`${providerLabel} request failed: timed out after ${String(REQUEST_TIMEOUT_MS)}ms.`, { cause: error }); } const message = error instanceof Error ? error.message : String(error); throw new Error(`${providerLabel} request failed: ${message}`, { cause: error, }); } finally { clearTimeout(timeoutId); } if (!response.ok) { await throwProviderHttpError(providerConfig, response); } const payload = await response.json(); const content = getContent(payload); if (content === undefined) { throw new Error(`${providerLabel} response did not include assistant text content.`); } return parseLlmResponseObject(content); } export function requestAiJsonObject(providerConfig, prompt) { return requestProviderObject(providerConfig, prompt); }