import type { AiProvider, Prompt } from "@workspace/db"; import type { ParsedFile, RawFinding, Severity, Axis } from "./ruleCatalog"; import { languageDirective, t, type Lang } from "./i18n"; const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "info"]; const AXES: Axis[] = ["security", "privacy"]; export type AiRuleConfig = { ruleId: string; title: string; description: string; axis: Axis; severity: Severity; }; export type AiResult = { findings: RawFinding[]; error: string | null; }; const FETCH_TIMEOUT_MS = 60000; function redactSecrets(text: string, token: string | null | undefined): string { let out = text; if (token && token.length >= 4) { out = out.split(token).join("[REDACTED]"); } out = out.replace(/(Bearer\s+)[A-Za-z0-9._\-]+/gi, "$1[REDACTED]"); out = out.replace(/\bsk-[A-Za-z0-9._\-]{8,}\b/g, "[REDACTED]"); return out; } async function fetchWithTimeout( url: string, init: RequestInit, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { return await fetch(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } } function buildSkillPayload(files: ParsedFile[]): string { const parts: string[] = []; let budget = 60000; for (const f of files) { if (f.content === "") continue; const header = `\n===== DATEI: ${f.path} (${f.kind}) =====\n`; const body = f.content.slice(0, 16000); if (header.length + body.length > budget) { parts.push(header + body.slice(0, Math.max(0, budget - header.length))); break; } parts.push(header + body); budget -= header.length + body.length; } return parts.join("\n"); } function coerceFinding( raw: unknown, allowed: Map, ): RawFinding | null { if (!raw || typeof raw !== "object") return null; const o = raw as Record; const title = typeof o.title === "string" ? o.title.slice(0, 200) : null; if (!title) return null; const rule = typeof o.ruleId === "string" ? allowed.get(o.ruleId) : undefined; if (!rule) { return null; } return { ruleId: rule.ruleId, axis: rule.axis, severity: rule.severity, title, description: typeof o.description === "string" ? o.description.slice(0, 2000) : title, remediation: typeof o.remediation === "string" ? o.remediation.slice(0, 2000) : null, file: typeof o.file === "string" ? o.file.slice(0, 400) : null, line: typeof o.line === "number" ? o.line : null, snippet: typeof o.snippet === "string" ? o.snippet.slice(0, 400) : null, detectedBy: "ai", }; } function extractJson(text: string): unknown { const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidate = fence ? fence[1] : text; const start = candidate.indexOf("{"); const end = candidate.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) { throw new Error("Keine JSON-Antwort von der KI erhalten."); } return JSON.parse(candidate.slice(start, end + 1)); } const ENDPOINT_SUFFIXES = ["/chat/completions", "/completions", "/messages"]; export function normalizeBaseUrl(raw: string): string { let url = raw.replace(/\/+$/, ""); for (const suffix of ENDPOINT_SUFFIXES) { if (url.endsWith(suffix)) { url = url.slice(0, url.length - suffix.length).replace(/\/+$/, ""); break; } } return url; } async function callOpenAiCompatible( provider: AiProvider, system: string, user: string, ): Promise { const base = normalizeBaseUrl(provider.baseUrl); const url = `${base}/chat/completions`; const res = await fetchWithTimeout(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiToken ?? ""}`, }, body: JSON.stringify({ model: provider.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, ], }), }); if (!res.ok) { const body = await res.text(); if (body.includes("v1/responses")) { throw new Error( `Das Modell "${provider.model}" unterstützt nur /v1/responses, nicht /v1/chat/completions. ` + `Bitte wählen Sie ein Chat-kompatibles Modell (z.\u202fB. gpt-4o, gpt-4-turbo, gpt-3.5-turbo).`, ); } throw new Error( `HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`, ); } const data = (await res.json()) as { choices?: { message?: { content?: string } }[]; }; return data.choices?.[0]?.message?.content ?? ""; } async function callAnthropic( provider: AiProvider, system: string, user: string, ): Promise { const base = normalizeBaseUrl(provider.baseUrl); const url = `${base}/messages`; const res = await fetchWithTimeout(url, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": provider.apiToken ?? "", "anthropic-version": "2023-06-01", }, body: JSON.stringify({ model: provider.model, max_tokens: 4096, system, messages: [{ role: "user", content: user }], }), }); if (!res.ok) { const body = await res.text(); throw new Error( `HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`, ); } const data = (await res.json()) as { content?: { text?: string }[] }; return data.content?.[0]?.text ?? ""; } export async function callProvider( provider: AiProvider, system: string, user: string, ): Promise { if (provider.apiType === "anthropic") { return callAnthropic(provider, system, user); } return callOpenAiCompatible(provider, system, user); } export async function listProviderModels( provider: AiProvider, ): Promise { const base = normalizeBaseUrl(provider.baseUrl); const url = `${base}/models`; const headers: Record = provider.apiType === "anthropic" ? { "x-api-key": provider.apiToken ?? "", "anthropic-version": "2023-06-01", } : { Authorization: `Bearer ${provider.apiToken ?? ""}`, }; const res = await fetchWithTimeout(url, { method: "GET", headers }); if (!res.ok) { const body = await res.text(); throw new Error( `HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`, ); } const data = (await res.json()) as { data?: { id?: unknown }[]; models?: { id?: unknown; name?: unknown }[]; }; const rows = Array.isArray(data.data) ? data.data : Array.isArray(data.models) ? data.models : []; const ids = rows .map((m) => typeof m.id === "string" ? m.id : typeof (m as { name?: unknown }).name === "string" ? ((m as { name: string }).name) : null, ) .filter((id): id is string => !!id); return Array.from(new Set(ids)).sort((a, b) => a.localeCompare(b)); } function buildRuleMenu(aiRules: AiRuleConfig[]): string { const lines = aiRules.map( (r) => `- ${r.ruleId} (${r.axis}): ${r.title} — ${r.description}`, ); return [ "", "Ordne jeden Befund GENAU EINER der folgenden aktiven Kategorien zu und gib deren Kennung im Pflichtfeld \"ruleId\" zurück. Verwende ausschließlich diese Kennungen:", ...lines, 'Befunde, die zu keiner dieser Kategorien passen, lasse weg. Das Feld "severity" wird serverseitig festgelegt und kann von dir ignoriert werden.', ].join("\n"); } export async function generateSkillDescription( provider: AiProvider, prompts: Prompt[], files: ParsedFile[], lang: Lang = "de", ): Promise { const descriptionPrompt = prompts.find((p) => p.key === "description")?.content ?? ""; if (!descriptionPrompt) return null; const systemPrompt = prompts.find((p) => p.key === "system")?.content ?? ""; const payload = buildSkillPayload(files); const user = `${descriptionPrompt}\n\n${languageDirective(lang)}\n\nHier ist das zu beschreibende Skill:\n${payload}`; try { const content = await callProvider(provider, systemPrompt, user); const parsed = extractJson(content) as { description?: unknown }; const description = typeof parsed.description === "string" ? parsed.description.trim() : ""; return description ? description.slice(0, 2000) : null; } catch { return null; } } export async function runAiAnalysis( provider: AiProvider, prompts: Prompt[], files: ParsedFile[], aiRules: AiRuleConfig[], lang: Lang = "de", ): Promise { if (aiRules.length === 0) { return { findings: [], error: null }; } const allowed = new Map(aiRules.map((r) => [r.ruleId, r])); const systemPrompt = prompts.find((p) => p.key === "system")?.content ?? ""; const analysisPrompt = prompts.find((p) => p.key === "analysis")?.content ?? ""; const payload = buildSkillPayload(files); const user = `${analysisPrompt}\n${buildRuleMenu(aiRules)}\n\n${languageDirective(lang)}\n\nHier ist das zu prüfende Skill:\n${payload}`; try { const content = await callProvider(provider, systemPrompt, user); const parsed = extractJson(content) as { findings?: unknown[] }; const findingsRaw = Array.isArray(parsed.findings) ? parsed.findings : []; const findings = findingsRaw .map((f) => coerceFinding(f, allowed)) .filter((f): f is RawFinding => f !== null); return { findings, error: null }; } catch (err) { return { findings: [], error: err instanceof Error ? err.message : t("aiUnknownError", lang), }; } }