VirtualLabo / server /aiMixMiddleware.ts
rinogeek's picture
Update
e1633a4
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<string, string | undefined>;
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<string> {
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: <tes 2 a 3 phrases ici>\n" +
"Equation: <equation chimique avec -> 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<string, string>;
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<string>();
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);
},
};
}