Add detection for OpenAI models that only support v1/responses and are not compatible with chat completions, providing user-friendly warnings during model selection and clearer error messages upon connection testing or AI analysis execution. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 0d01f99a-ea6a-447d-82fd-311715434a39 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: ac489071-6c6a-4584-9740-76bf6ca16040 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/e32d2b99-1721-47dd-833c-98b372f48008/0d01f99a-ea6a-447d-82fd-311715434a39/upEITG1 Replit-Helium-Checkpoint-Created: true
305 lines
9.4 KiB
TypeScript
305 lines
9.4 KiB
TypeScript
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<Response> {
|
|
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<string, AiRuleConfig>,
|
|
): RawFinding | null {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const o = raw as Record<string, unknown>;
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
if (provider.apiType === "anthropic") {
|
|
return callAnthropic(provider, system, user);
|
|
}
|
|
return callOpenAiCompatible(provider, system, user);
|
|
}
|
|
|
|
export async function listProviderModels(
|
|
provider: AiProvider,
|
|
): Promise<string[]> {
|
|
const base = normalizeBaseUrl(provider.baseUrl);
|
|
const url = `${base}/models`;
|
|
const headers: Record<string, string> =
|
|
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<string | null> {
|
|
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<AiResult> {
|
|
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),
|
|
};
|
|
}
|
|
}
|