import type { Plugin, PreviewServer, ViteDevServer } from "vite"; import { substances } from "../src/data/substances"; import https from "node:https"; import { URL } from "node:url"; type Env = Record; class UpstreamError extends Error { status: number; upstreamMessage?: string; constructor(status: number, message: string, upstreamMessage?: string) { super(message); this.status = status; this.upstreamMessage = upstreamMessage; } } function readBody(req: any): Promise { return new Promise((resolve, reject) => { let data = ""; req.on("data", (chunk: any) => { data += chunk; }); req.on("end", () => resolve(data)); req.on("error", reject); }); } function sendJson(res: any, status: number, payload: unknown) { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(payload)); } function pickSubstances(substanceIds: string[]) { const uniqueIds = Array.from(new Set(substanceIds)).filter(Boolean); return uniqueIds .map((id) => substances.find((s) => s.id === id)) .filter(Boolean); } function buildPrompt(args: { temperatureC: number; substances: Array<{ id: string; name: string; formula: string; state: string; hazard: string; description: string; }>; }) { const lines = args.substances.map((s) => [ `- id: ${s.id}`, ` nom: ${s.name}`, ` formule: ${s.formula}`, ` etat: ${s.state}`, ` danger: ${s.hazard}`, ` description: ${s.description}`, ].join("\n"), ); const system = "Tu es un assistant de laboratoire de chimie. " + "Decris l'observation du melange en etant TRES CONCIS (MAXIMUM 2 a 3 phrases), en allant droit au but et en enlevant tous les details inutiles. Inclus les rappels de securite si necessaire. " + "Donne ensuite l'equation correspondante. " + "Termine avec une application pratique de ce melange dans la vie courante ou l'industrie (ex: 'ce melange sert a la fabrication de detergent'). " + "Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" + "Observation: \n" + "Equation: ou -> ou →>\n" + "Application: <1 a 2 phrases sur l'utilite pratique>"; const user = [ "Contexte:", `- Temperature du milieu: ${args.temperatureC} degres Celsius`, `- Substances presentes (a melanger):`, ...lines, "", "Format attendu strictement: ", "Observation: <...>", "Equation: <...>", "Application: <...>" ].join("\n"); return { system, user }; } function countSentences(text: string) { // Heuristic: count sentence terminators. const matches = text.match(/[.!?](\s|$)/g); return matches ? matches.length : 0; } function normalizeOneParagraph(text: string) { return (text || "") .replace(/\s+/g, " ") .replace(/\u00a0/g, " ") .trim(); } function validateObservation(args: { text: string; temperatureC: number; hasHazard: boolean; }) { const t = args.text.trim(); if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" }; if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" }; if (!/Application\s*:/i.test(t)) return { ok: false, reason: "application manquante (Application:)" }; const parts = t.split(/Equation\s*:/i); const observation = parts[0].replace(/^Observation\s*:/i, '').trim(); const lowerParts = parts[1].split(/Application\s*:/i); const equation = lowerParts[0].trim(); const application = lowerParts[1] ? lowerParts[1].trim() : ""; if (observation.length < 10) return { ok: false, reason: "observation trop courte" }; const sentenceCount = countSentences(observation); if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` }; if (equation.length < 2) return { ok: false, reason: "equation invalide" }; if (application.length < 10) return { ok: false, reason: "application invalide ou trop courte" }; if (args.hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) { // on l'accepte tout de meme } return { ok: true, reason: null, parsed: { observation, equation, application } }; } async function httpsPostJson(args: { url: string; headers: Record; body: unknown; }): Promise<{ status: number; statusText: string; text: string; json: any }> { const u = new URL(args.url); const bodyStr = JSON.stringify(args.body); return new Promise((resolve, reject) => { const req = https.request( { protocol: u.protocol, hostname: u.hostname, port: u.port ? Number(u.port) : undefined, path: `${u.pathname}${u.search}`, method: "POST", headers: { ...args.headers, "Content-Length": Buffer.byteLength(bodyStr).toString(), }, }, (res) => { let data = ""; res.setEncoding("utf8"); res.on("data", (chunk) => (data += chunk)); res.on("end", () => { let parsed: any = null; try { parsed = data ? JSON.parse(data) : null; } catch { parsed = null; } resolve({ status: res.statusCode || 0, statusText: res.statusMessage || "", text: data, json: parsed, }); }); }, ); req.on("error", reject); req.write(bodyStr); req.end(); }); } function extractResponsesText(data: any): string | null { if (typeof data?.output_text === "string" && data.output_text.trim()) { return data.output_text.trim(); } const chunks: string[] = []; const outputs = Array.isArray(data?.output) ? data.output : []; for (const out of outputs) { // OpenAI Responses often returns items with type "message" and a content array. const content = Array.isArray(out?.content) ? out.content : []; for (const part of content) { if (part?.type === "output_text" && typeof part?.text === "string") chunks.push(part.text); if (part?.type === "text" && typeof part?.text === "string") chunks.push(part.text); } // Some providers nest content under message objects. const msgContent = Array.isArray(out?.message?.content) ? out.message.content : []; for (const part of msgContent) { if (part?.type === "output_text" && typeof part?.text === "string") chunks.push(part.text); if (part?.type === "text" && typeof part?.text === "string") chunks.push(part.text); } } const text = chunks.join("\n").trim(); return text || null; } async function callGroqChatCompletionsText(args: { apiKey: string; model: string; system: string; user: string; }) { let resp: { status: number; statusText: string; text: string; json: any }; try { resp = await httpsPostJson({ url: "https://api.groq.com/openai/v1/chat/completions", headers: { "Content-Type": "application/json", Authorization: `Bearer ${args.apiKey}`, }, body: { model: args.model, messages: [ { role: "system", content: args.system }, { role: "user", content: args.user }, ], temperature: 0.2, max_tokens: 350, }, }); } catch (e: any) { const code = e?.code ? String(e.code) : "NETWORK_ERROR"; const msg = e?.message ? String(e.message) : "Network error"; throw new UpstreamError(0, `Network error calling Groq: ${code} ${msg}`); } if (resp.status < 200 || resp.status >= 300) { const upstreamMessage = resp?.json?.error?.message || resp?.json?.message; const msg = upstreamMessage ? `Groq API error ${resp.status}: ${upstreamMessage}` : `Groq API error ${resp.status}: ${resp.statusText || "Unknown"}`; throw new UpstreamError(resp.status, msg, upstreamMessage); } const data = resp.json; if (!data || typeof data !== "object") { throw new UpstreamError(502, "Groq Chat Completions returned non-JSON response"); } const choice = Array.isArray((data as any).choices) ? (data as any).choices[0] : undefined; const msg = choice?.message; // Standard OpenAI-compatible content. const content = msg?.content; if (typeof content === "string" && content.trim()) return content.trim(); // Legacy completion-style field (some providers/models). const choiceText = choice?.text; if (typeof choiceText === "string" && choiceText.trim()) return choiceText.trim(); // Sometimes providers include a delta even in non-stream mode. const deltaContent = choice?.delta?.content; if (typeof deltaContent === "string" && deltaContent.trim()) return deltaContent.trim(); // Some providers can return content as an array of parts. if (Array.isArray(content)) { const parts = content .map((p: any) => (typeof p?.text === "string" ? p.text : "")) .filter(Boolean); const joined = parts.join("\n").trim(); if (joined) return joined; } // Some providers return content as an object part (not array). if (content && typeof content === "object") { const objText = (content as any).text; if (typeof objText === "string" && objText.trim()) return objText.trim(); } // Refusal format (treat as recoverable so the next model can be tried). const refusal = msg?.refusal; if (typeof refusal === "string" && refusal.trim()) { throw new UpstreamError(422, "Model refusal", refusal.trim()); } // Some models put content into a "reasoning" field and leave content empty. // Treat as recoverable; the caller will try another model. const reasoning = msg?.reasoning; if (typeof reasoning === "string" && reasoning.trim()) { const candidate = normalizeOneParagraph(reasoning); if (/equation\s*:/i.test(candidate) && /application\s*:/i.test(candidate) && /(->|→)/.test(candidate)) { return candidate; } throw new UpstreamError(422, "Model returned reasoning without final content"); } // Tool calls without text (recoverable, try next model). const toolCalls = msg?.tool_calls; if (Array.isArray(toolCalls) && toolCalls.length > 0) { const sample = JSON.stringify(toolCalls[0]).slice(0, 500); throw new UpstreamError(422, "Model returned tool_calls with no text", sample); } const keys = Object.keys(data).slice(0, 12).join(","); // Log a small sample for debugging without returning it to the client. try { console.error("[groq chat] empty content choice sample:", JSON.stringify(choice).slice(0, 900)); } catch { // ignore } throw new UpstreamError(502, `Groq Chat Completions returned no message content (keys: ${keys})`); } async function callGroqChatCompletion(args: { apiKey: string; model: string; system: string; user: string; }) { let resp: { status: number; statusText: string; text: string; json: any }; try { resp = await httpsPostJson({ url: "https://api.groq.com/openai/v1/responses", headers: { "Content-Type": "application/json", Authorization: `Bearer ${args.apiKey}`, }, body: { model: args.model, instructions: args.system, input: args.user, temperature: 0.2, max_output_tokens: 350, }, }); } catch (e: any) { const code = e?.code ? String(e.code) : "NETWORK_ERROR"; const msg = e?.message ? String(e.message) : "Network error"; throw new UpstreamError(0, `Network error calling Groq: ${code} ${msg}`); } if (resp.status < 200 || resp.status >= 300) { const upstreamMessage = resp?.json?.error?.message || resp?.json?.message; const msg = upstreamMessage ? `Groq API error ${resp.status}: ${upstreamMessage}` : `Groq API error ${resp.status}: ${resp.statusText || "Unknown"}`; throw new UpstreamError(resp.status, msg, upstreamMessage); } const data: any = resp.json; const extracted = extractResponsesText(data); if (extracted) return extracted; // Some models/providers may not support Responses output_text cleanly; fallback to Chat Completions. try { return await callGroqChatCompletionsText(args); } catch (e) { // Preserve upstream errors for model fallback logic. throw e; } } function parseModelList(env: Env) { const rawList = (env.GROQ_MODELS || process.env.GROQ_MODELS || "").trim(); const single = (env.GROQ_MODEL || process.env.GROQ_MODEL || "").trim(); const fromList = rawList ? rawList .split(",") .map((s) => s.trim()) .filter(Boolean) : []; const seed = fromList.length > 0 ? fromList : single ? [single] : []; const deduped: string[] = []; const seen = new Set(); for (const m of seed) { if (seen.has(m)) continue; seen.add(m); deduped.push(m); } return deduped; } function defaultModelList() { // Ordered fallback list (deduped). return [ "openai/gpt-oss-120b", "llama-3.1-8b-instant", "llama-3.3-70b-versatile", "openai/gpt-oss-20b", ]; } function filterGenerationModels(models: string[]) { // "safeguard" models often return refusals or reasoning-only payloads; avoid them for generation. const filtered = models.filter((m) => !/safeguard/i.test(m)); return filtered.length > 0 ? filtered : models; } function shouldFallbackOnUpstreamError(err: UpstreamError) { // Do not fallback on auth errors: all models will fail the same way. if (err.status === 401 || err.status === 403) return false; // Rate limit / quota or temporary outages: try another model. if (err.status === 429) return true; if (err.status === 0) return true; // network error (might be transient) if (err.status >= 500 && err.status <= 599) return true; // Model unavailable / bad request: often model-specific. if (err.status === 400 || err.status === 404) return true; // Validation/refusal-type errors can be model-specific. if (err.status === 422) return true; return false; } function addRoutes(server: ViteDevServer | PreviewServer, env: Env) { server.middlewares.use("/api/ai/ping", async (req: any, res: any, next: any) => { if (req.method !== "GET") { res.statusCode = 405; res.setHeader("Allow", "GET"); return res.end(); } const apiKey = (env.GROQ_API_KEY || process.env.GROQ_API_KEY || "").trim(); const configured = parseModelList(env); const models = configured.length > 0 ? configured : defaultModelList(); const generationModels = filterGenerationModels(models); return sendJson(res, 200, { ok: true, hasGroqApiKey: !!apiKey && !apiKey.includes("your_groq_api_key_here"), groqApiKeyPrefix: apiKey ? apiKey.slice(0, 4) : null, models, generationModels, node: process.version, }); }); server.middlewares.use("/api/ai/mix", async (req: any, res: any, next: any) => { if (req.method !== "POST") { res.statusCode = 405; res.setHeader("Allow", "POST"); return res.end(); } const apiKey = (env.GROQ_API_KEY || process.env.GROQ_API_KEY || "").trim(); if (!apiKey || apiKey.includes("your_groq_api_key_here")) { return sendJson(res, 500, { error: "GROQ_API_KEY manquant. Ajoute-le dans .env (ne le commit pas).", }); } const triedModels: string[] = []; let lastModel: string | null = null; try { const body = await readBody(req); const parsed = body ? JSON.parse(body) : {}; const substanceIds: string[] = Array.isArray(parsed?.substanceIds) ? parsed.substanceIds : []; const temperatureC = Number(parsed?.temperatureC); const providedSubstances: any[] = Array.isArray(parsed?.substances) ? parsed.substances : []; const picked = (providedSubstances.length >= 2 ? providedSubstances : pickSubstances(substanceIds)) as any[]; if (picked.length < 2) { return sendJson(res, 400, { error: "substances/substanceIds doit contenir au moins 2 substances valides" }); } const safeTemp = Number.isFinite(temperatureC) ? temperatureC : 20; const { system, user } = buildPrompt({ temperatureC: safeTemp, substances: picked.map((s: any) => ({ id: s.id, name: s.name, formula: s.formula, state: s.state, hazard: s.hazard, description: s.description, })), }); const configured = parseModelList(env); const models = configured.length > 0 ? configured : defaultModelList(); const generationModels = filterGenerationModels(models); let lastErr: unknown = null; const hasHazard = picked.some((s: any) => s.hazard && s.hazard !== "none"); for (const model of generationModels) { triedModels.push(model); lastModel = model; let lastValidation: { ok: boolean; reason: string | null } | null = null; for (let attempt = 1; attempt <= 2; attempt++) { try { const attemptUser = attempt === 1 ? user : `${user}\n\nLe texte precedent ne respecte pas les contraintes (${lastValidation?.reason || "incomplet"}). Regenerate en respectant STRICTEMENT le format attendu sans sauts de lignes et sans details.`; let text = await callGroqChatCompletion({ apiKey, model, system, user: attemptUser }); text = normalizeOneParagraph(text); const v = validateObservation({ text, temperatureC: safeTemp, hasHazard }); lastValidation = v as { ok: boolean; reason: string | null }; if (v.ok) return sendJson(res, 200, { text: (v as any).parsed, modelUsed: model, triedModels }); } catch (e: any) { lastErr = e; if (e instanceof UpstreamError) { if (!shouldFallbackOnUpstreamError(e)) throw e; // Upstream error: do not keep retrying the same model; try next. break; } throw e; } } // Model responded but didn't meet quality constraints twice: try next model. lastErr = lastErr || new UpstreamError(400, `Validation failed for model ${model}`, lastValidation?.reason || "validation failed"); } // All models failed. if (lastErr instanceof UpstreamError) throw lastErr; throw new Error("All models failed"); } catch (err: any) { const message = err?.message || "Erreur lors de la generation IA"; // Avoid leaking secrets; log the error for debugging. console.error("[/api/ai/mix] error:", message); if (err instanceof UpstreamError) { if (err.status === 401 || err.status === 403) { return sendJson(res, 401, { error: "API key Groq invalide ou non autorisee" }); } if (err.status === 429) { return sendJson(res, 429, { error: "Quota/limite Groq atteinte (429). Reessaye plus tard." }); } // Pass through common client errors to make debugging actionable. if (err.status >= 400 && err.status < 500) { return sendJson(res, err.status, { error: message, triedModels, lastModel, }); } return sendJson(res, 502, { error: "Erreur upstream Groq lors de la generation IA", details: message, triedModels, lastModel, }); } // Local server-side error (ex: Node too old -> fetch undefined, JSON parse, etc.) return sendJson(res, 500, { error: "Erreur serveur /api/ai/mix (pas une reponse Groq). Verifie la console serveur et Node >= 18.", details: message, triedModels, lastModel, }); } }); } export function aiMixMiddleware(env: Env): Plugin { return { name: "ai-mix-middleware", configureServer(server) { addRoutes(server, env); }, configurePreviewServer(server) { addRoutes(server, env); }, }; }