feat: implement admin AI chat route with SQL execution and database schema context

This commit is contained in:
Michael committed 2026-04-10 16:03:00 +02:00
1 parent 6e10364922
commit 8dd2394893
1 file changed
+167 -260
+167 -260
View File
@@ -1,6 +1,5 @@
import { NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { getDb } from "@/db";
import fs from "fs/promises";
import path from "path";
import { GoogleGenAI, Type } from "@google/genai";
@@ -32,199 +31,185 @@ async function getAiConfig(): Promise<AiConfig> {
}
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
// Tools available to the admin AI
const TOOLS = [
// ─── Tool definitions ────────────────────────────────────────────────────────
const TOOLS_OPENROUTER = [
{
type: "function",
function: {
name: "execute_sql",
description:
"Execute a READ-ONLY SQL SELECT query on the CollectFlow PostgreSQL database. Use this to answer questions about data.",
name: "call_api",
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
parameters: {
type: "object",
properties: {
query: {
endpoint: {
type: "string",
description:
"A valid PostgreSQL SELECT query. Only SELECT statements are allowed.",
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
},
},
required: ["query"],
},
},
},
{
type: "function",
function: {
name: "get_db_schema",
description:
"Returns the full database schema with table names, columns, and descriptions.",
parameters: {
type: "object",
properties: {},
required: [],
required: ["endpoint"],
},
},
},
];
const DB_SCHEMA = `
# Schéma complet de la base de données CollectFlow (PostgreSQL)
const TOOLS_GOOGLE = [{
functionDeclarations: [
{
name: "call_api",
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
parameters: {
type: Type.OBJECT,
properties: {
endpoint: {
type: Type.STRING,
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
},
},
required: ["endpoint"],
},
},
],
}];
## Table : ventes_produits
Table principale. Contient l'historique des ventes par produit, par magasin, par mois.
Chaque ligne = 1 produit × 1 magasin × 1 mois.
// ─── API caller ───────────────────────────────────────────────────────────────
Colonnes :
- id (serial PK)
- codein (varchar) — identifiant produit interne unique FF Nancy
- code_fournisseur (varchar) — code fournisseur (ex: "FOU001")
- nom_fournisseur (varchar) — nom complet du fournisseur
- libelle1 (varchar) — libellé principal du produit
- gtin (varchar) — code-barres EAN
- reference (varchar) — référence produit fournisseur
- colisage (numeric) — quantité par colis
- code_gamme (varchar) — classification actuelle : A, B, C, D, ou Z
- code_gamme_init (varchar) — classification d'origine à l'import
- code3 (varchar) — code sous-catégorie produit
- libelle3 (varchar) — libellé sous-catégorie
- magasin (varchar) — nom ou code magasin (ex: "Supermarché Est")
- code_magasin (varchar) — identifiant court magasin (ex: "001")
- annee (smallint) — année de la période (ex: 2024)
- mois (smallint) — mois de la période (1-12)
- periode (varchar) — format "YYYY-MM" (ex: "2024-03")
- quantite (numeric) — quantité vendue sur la période
- montant_mvt (numeric) — chiffre d'affaires HT en euros
- marge_mvt (numeric) — marge brute HT en euros
- prix_unitaire (numeric) — prix de vente unitaire HT (si disponible)
- imported_at (timestamp) — date d'import
- updated_at (timestamp) — dernière mise à jour
async function callFfApi(endpoint: string): Promise<string> {
// Sécurité : uniquement GET, uniquement les endpoints documentés
const allowedPrefixes = [
"/api/health", "/api/sync", "/api/schema",
"/api/articles", "/api/stock", "/api/fournisseurs",
"/api/commandes", "/api/mouvements", "/api/performance",
"/api/publicites", "/api/commandes-auto",
];
const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p));
if (!isAllowed) {
return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`;
}
Requêtes utiles sur ventes_produits :
- Lister les fournisseurs distincts : SELECT DISTINCT code_fournisseur, nom_fournisseur FROM ventes_produits ORDER BY nom_fournisseur
- CA total par fournisseur : SELECT nom_fournisseur, SUM(montant_mvt) as ca FROM ventes_produits GROUP BY nom_fournisseur ORDER BY ca DESC
- Produits par gamme : SELECT code_gamme, COUNT(DISTINCT codein) FROM ventes_produits GROUP BY code_gamme
- Évolution mensuelle : SELECT periode, SUM(montant_mvt) as ca FROM ventes_produits GROUP BY periode ORDER BY periode
- Produits d'un fournisseur : SELECT DISTINCT codein, libelle1, code_gamme FROM ventes_produits WHERE code_fournisseur = '...'
try {
const url = `${FF_API_BASE}${endpoint}`;
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
cache: "no-store",
});
## Table : users
Utilisateurs de l'application CollectFlow.
if (!res.ok) {
return `Erreur API ${res.status}: ${await res.text()}`;
}
Colonnes :
- id (serial PK)
- username (varchar unique) — identifiant de connexion
- password_hash (text) — ⛔ NE JAMAIS EXPOSER CE CHAMP
- role (varchar) — 'admin' (accès total) ou 'user' (accès restreint)
- created_at (timestamp)
const data = await res.json();
const text = JSON.stringify(data);
## Table : session_snapshots
Historique des sessions d'arbitrage de gamme sauvegardées par les utilisateurs.
Un snapshot = une session de travail où un utilisateur a modifié des classifications gamme.
// Tronquer si trop long (max 6000 chars)
if (text.length > 6000) {
return text.substring(0, 6000) + `\n...(tronqué, ${text.length} caractères total)`;
}
return text;
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
return `Erreur réseau: ${msg}`;
}
}
Colonnes :
- id (serial PK)
- user_id (int FK → users.id)
- code_fournisseur (varchar)
- nom_fournisseur (varchar)
- magasin (varchar)
- changes (jsonb) — objet JSON : { "codein": { "before": "A", "after": "B" }, ... }
- summary_json (jsonb) — statistiques : { "total": N, "byGamme": {...}, ... }
- label (text) — nom donné au snapshot par l'utilisateur
- type (varchar) — 'snapshot' (sauvegarde manuelle) ou 'export' (export Excel/PDF)
- created_at (timestamp)
// ─── System prompt ────────────────────────────────────────────────────────────
Requêtes utiles sur session_snapshots :
- Derniers snapshots : SELECT id, label, nom_fournisseur, magasin, type, created_at FROM session_snapshots ORDER BY created_at DESC LIMIT 20
- Compter les changements d'un snapshot : SELECT id, label, jsonb_object_keys(changes) FROM session_snapshots
- Snapshots par utilisateur : SELECT u.username, COUNT(*) FROM session_snapshots s JOIN users u ON u.id = s.user_id GROUP BY u.username
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire (Foirfouille Nancy).
## Table : ai_supplier_context
Contexte métier personnalisé par fournisseur, saisi par les managers pour guider l'IA d'analyse de gamme.
## Outil disponible
- \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON
Colonnes :
- code_fournisseur (varchar PK)
- context (text) — instructions et règles métier spécifiques au fournisseur
- updated_at (timestamp)
## API FF Nancy — Documentation complète
Base URL : ${FF_API_BASE}
Format des dates : YYYY-MM-DD
Identifiants articles : toujours \`no_id\` (entier interne)
Codes site : ex 000, 001, 002…
## Règles métier — Classification gamme (A/B/C/D/Z)
- **A** : Produit pilier — fort volume, fort CA, indispensable au rayon
- **B** : Bon produit — bonne rotation, CA correct
- **C** : Produit de performance — faible rotation mais maintenu pour compléter l'offre
- **D** : Produit saisonnier ou de niche — présence justifiée par événement ou spécificité
- **Z** : Produit en sortie de gamme — à déréférencer
### Santé / Infra
- GET /api/health — vérifie que l'API et PostgreSQL répondent
- GET /api/sync/status — état de la synchronisation en cours
- GET /api/schema/tables?search=nom — liste les tables PG
- GET /api/schema/tables/:nom — colonnes d'une table
- GET /api/schema/top?limit=10 — tables les plus peuplées
## Informations sur l'application CollectFlow
- Application web Next.js de gestion de gammes produits pour des magasins de distribution alimentaire
- Les données viennent d'une API externe (FF Nancy) synchronisée chaque nuit
- Plusieurs magasins peuvent être gérés, chacun a son propre assortiment
- Les utilisateurs arbitrent manuellement les gammes produit par fournisseur
- Les sessions sont sauvegardées comme snapshots pour historique et audit
`;
### Articles
- GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page)
- GET /api/articles/:id — détail complet d'un article (no_id)
- GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN
- GET /api/articles/:id/mouvements — historique des mouvements
- GET /api/articles/:id/mensuel — CA mensuel de l'article
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire.
Le champ **prix d'achat (PA)** est retourné directement dans GET /api/articles (champ \`pa\` ou \`prix_achat\`).
Pour un article précis : GET /api/articles/:id/referentiel pour voir tous les tarifs fournisseurs.
## Outils disponibles
- \`execute_sql\` : exécute une requête SELECT sur la base PostgreSQL
- \`get_db_schema\` : retourne le schéma complet de la base de données
### Stock
- GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page)
- GET /api/stock/article/:id — stock par site pour un article (no_id) → contient aussi \`prmp\` (Prix de Revient Moyen Pondéré)
- GET /api/stock/site/:site — tous les articles d'un site donné
- GET /api/stock/article/:id/historique — historique stock de l'article
- GET /api/stock/article/:id/periode?dateDebut=&dateFin= — stock sur période
- GET /api/stock/valorisation — valeur totale du stock par site
Le schéma complet est déjà inclus dans ton contexte ci-dessous — utilise \`get_db_schema\` uniquement si tu as besoin de détails supplémentaires.
Le **PRMP** (prix réel moyen pondéré) est dans GET /api/stock/article/:id, champ \`prmp\` par site.
## Schéma de la base de données
${DB_SCHEMA}
### Fournisseurs
- GET /api/fournisseurs?search=nom — liste des fournisseurs (champs : code, nom)
- GET /api/fournisseurs/:code/articles — articles du fournisseur
- GET /api/fournisseurs/:code/commandes — commandes du fournisseur
## Règles SQL strictes
- N'utilise JAMAIS INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE
- N'expose JAMAIS le champ \`password_hash\` de la table \`users\`
- Toujours ajouter LIMIT (max 200 lignes par défaut, sauf agrégats)
- Pour les analyses larges, utilise des agrégats (SUM, COUNT, AVG, GROUP BY)
- Préfère des requêtes précises et ciblées plutôt que SELECT *
- Si une question nécessite plusieurs requêtes, enchaîne-les avec plusieurs appels \`execute_sql\`
### Commandes
- GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs
- GET /api/commandes/:noCommande — détail d'une commande + ses lignes
- GET /api/commandes/receptions/liste — liste des réceptions
### Commandes automatiques
- GET /api/commandes-auto?site=&codefou= — résumé des propositions par fournisseur/site avec comparaison franco
Champs : franco_ht, montant_propo_ht, franco_atteint (OUI/NON/N/A), ecart_franco
- GET /api/commandes-auto/:codefou?site= — détail d'un fournisseur : résumé + liste des articles proposés avec stock et montant
### Mouvements
- GET /api/mouvements/types — types de mouvements disponibles
- GET /api/mouvements/articles?dateDebut=&dateFin=&site= — mouvements par article
- GET /api/mouvements/entrees?dateDebut=&dateFin= — entrées marchandises
- GET /api/mouvements/regularisations?dateDebut=&dateFin= — régularisations inventaire
- GET /api/mouvements/reglements?dateDebut=&dateFin= — règlements
- GET /api/mouvements/synthese?dateDebut=&dateFin=&site= — synthèse des mouvements
### Performance / CA
- GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=mois — CA global (groupBy: jour ou mois)
- GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=20&groupBy=ca — top articles (groupBy: qte, ca ou marge)
- GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau= — CA par nomenclature
- GET /api/performance/ca/fournisseur?dateDebut=&dateFin=&site= — CA par fournisseur
- GET /api/performance/ca/gamme?dateDebut=&dateFin=&site= — CA par gamme (A/B/C/D/Z)
- GET /api/performance/dashboard — indicateurs résumés pour dashboard
### Publicités
- GET /api/publicites?search=&site=&dateDebut=&dateFin=&page=1&limit=50 — liste des publicités
- GET /api/publicites/:code?site= — détail d'une publicité
## Contexte métier CollectFlow
- Application de gestion de gammes pour magasins Foirfouille Nancy
- Gammes A/B/C/D/Z : A=pilier, B=bonne rotation, C=performance, D=saisonnier/niche, Z=sortie
- Les ventes sont suivies par magasin (site) et par mois
- Les utilisateurs arbitrent les classifications gamme par fournisseur
## Stratégie d'analyse
1. Commence toujours par explorer les données disponibles si tu ne connais pas les valeurs exactes (codes fournisseurs, codes magasins, périodes disponibles)
2. Adapte tes requêtes aux données réelles trouvées
3. Présente les résultats de façon lisible avec des tableaux, listes et titres
1. Si tu ne connais pas un code fournisseur ou un no_id, commence par chercher avec /api/fournisseurs?search= ou /api/articles?search=
2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse
3. Pour le prix d'achat : utilise /api/articles?codefou=CODE (champ pa) ou /api/articles/:id/referentiel
4. Pour le PRMP (prix réel) : utilise /api/stock/article/:id (champ prmp par site)
5. Ajoute toujours \`limit\` pour éviter des réponses trop volumineuses (limit=20 à 100 selon le contexte)
## Format de réponse — IMPORTANT
- Réponds TOUJOURS en **Markdown** : utilise ## titres, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code \`\`\`sql
- Réponds TOUJOURS en **Markdown** : titres ##, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code
- N'utilise JAMAIS de HTML (<div>, <table>, <b>, <br>, etc.)
- Réponds toujours en français
- Sois précis, structuré et complet — ne tronque jamais ta réponse
- Si les données sont nombreuses, résume et mets en avant les points clés`;
async function executeSql(query: string): Promise<string> {
// Security: only allow SELECT statements
const normalizedQuery = query.trim().toUpperCase();
if (!normalizedQuery.startsWith("SELECT") && !normalizedQuery.startsWith("WITH")) {
return "Erreur: Seules les requêtes SELECT sont autorisées.";
}
// Block dangerous keywords
const blocked = ["INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER", "CREATE", "GRANT", "REVOKE"];
for (const kw of blocked) {
if (new RegExp(`\\b${kw}\\b`).test(normalizedQuery)) {
return `Erreur: Le mot-clé ${kw} n'est pas autorisé.`;
}
}
try {
const db = getDb();
const result = await db.execute(query as any);
const rows = result.rows ?? [];
if (rows.length === 0) return "Aucun résultat.";
// Return as compact JSON (max 200 rows)
const limited = rows.slice(0, 200);
const truncated = rows.length > 200 ? ` (résultats tronqués à 200/${rows.length})` : "";
return JSON.stringify(limited, null, 2) + truncated;
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
return `Erreur SQL: ${msg}`;
}
}
// ─── OpenRouter handler ───────────────────────────────────────────────────────
async function callOpenRouter(
messages: object[],
@@ -239,17 +224,14 @@ async function callOpenRouter(
temperature: 0.3,
max_tokens: 8192,
};
if (useTools) {
body.tools = TOOLS;
body.tools = TOOLS_OPENROUTER;
body.tool_choice = "auto";
}
if (stream) {
body.stream = true;
body.stream_options = { include_usage: true };
}
return fetch(OPENROUTER_URL, {
method: "POST",
headers: {
@@ -262,7 +244,8 @@ async function callOpenRouter(
});
}
// Google AI Studio handler
// ─── Google AI handler ────────────────────────────────────────────────────────
async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
if (!config.googleAiKey) {
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 });
@@ -271,25 +254,6 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
const model = config.googleAiModel ?? "gemini-2.0-flash";
const encoder = new TextEncoder();
const googleTools = [{
functionDeclarations: [
{
name: "execute_sql",
description: "Execute a READ-ONLY SQL SELECT query on the CollectFlow PostgreSQL database.",
parameters: {
type: Type.OBJECT,
properties: { query: { type: Type.STRING, description: "A valid PostgreSQL SELECT query." } },
required: ["query"],
},
},
{
name: "get_db_schema",
description: "Returns the full database schema with table names, columns, and descriptions.",
parameters: { type: Type.OBJECT, properties: {} },
},
],
}];
const stream = new ReadableStream({
async start(controller) {
function send(event: object) {
@@ -299,27 +263,24 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
try {
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
// Build full contents array for generateContent
const contents: Array<{ role: string; parts: Array<{ text: string }> }> = [
const contents: Array<{ role: string; parts: any[] }> = [
...messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
];
// Agentic loop — streaming throughout
for (let round = 0; round < 8; round++) {
for (let round = 0; round < 10; round++) {
const streamResponse = await ai.models.generateContentStream({
model,
contents: contents as any,
config: {
systemInstruction: SYSTEM_PROMPT,
tools: googleTools as any,
tools: TOOLS_GOOGLE as any,
maxOutputTokens: 8192,
},
});
// Collect stream, forwarding text chunks immediately
let collectedText = "";
const collectedParts: any[] = [];
@@ -329,28 +290,16 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
collectedText += text;
send({ type: "text", content: text });
}
// Collect parts for function calls
const parts = chunk.candidates?.[0]?.content?.parts;
if (parts) collectedParts.push(...parts);
}
// Check for function calls in collected parts
const fnCallParts = collectedParts.filter((p: any) => p.functionCall);
if (fnCallParts.length === 0) {
// Pure text response — done
break;
}
if (fnCallParts.length === 0) break;
// Had function calls — add model turn to history
if (collectedText) {
// Mixed text + function calls
contents.push({ role: "model", parts: collectedParts });
} else {
contents.push({ role: "model", parts: fnCallParts });
}
contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts });
// Execute tool calls
const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
for (const part of fnCallParts) {
const fn = part.functionCall as { name: string; args: Record<string, unknown> };
@@ -358,10 +307,8 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
send({ type: "tool_start", name: fn.name, args });
let result = "";
if (fn.name === "execute_sql") {
result = await executeSql(args.query as string);
} else if (fn.name === "get_db_schema") {
result = DB_SCHEMA;
if (fn.name === "call_api") {
result = await callFfApi(args.endpoint as string);
} else {
result = `Outil inconnu: ${fn.name}`;
}
@@ -393,15 +340,15 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
});
}
// ─── POST handler ─────────────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
// Auth check
const session = await auth();
if (!session || (session.user as any)?.role !== "admin") {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
}
const { messages, model } = await req.json();
const aiConfig = await getAiConfig();
if (aiConfig.provider === "google") {
@@ -413,13 +360,11 @@ export async function POST(req: NextRequest) {
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
}
// Build message list with system prompt
const fullMessages: object[] = [
{ role: "system", content: SYSTEM_PROMPT },
...messages,
];
// SSE stream
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
@@ -433,71 +378,51 @@ export async function POST(req: NextRequest) {
let totalCompletionTokens = 0;
let directAnswer: string | null = null;
// Tool-calling loop (non-streaming for tool phase)
for (let round = 0; round < 8; round++) {
for (let round = 0; round < 10; round++) {
const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
if (!res.ok) {
const errText = await res.text();
send({ type: "error", message: `OpenRouter error: ${errText}` });
send({ type: "error", message: `OpenRouter error: ${await res.text()}` });
controller.close();
return;
}
const data = await res.json();
const usage = data.usage;
if (usage) {
totalPromptTokens += usage.prompt_tokens ?? 0;
totalCompletionTokens += usage.completion_tokens ?? 0;
if (data.usage) {
totalPromptTokens += data.usage.prompt_tokens ?? 0;
totalCompletionTokens += data.usage.completion_tokens ?? 0;
}
const choice = data.choices?.[0];
const assistantMessage = choice?.message;
const assistantMessage = data.choices?.[0]?.message;
if (!assistantMessage) break;
currentMessages.push(assistantMessage);
const toolCalls = assistantMessage.tool_calls;
if (!toolCalls || toolCalls.length === 0) {
// Model gave a direct answer — capture it and skip the extra final call
directAnswer = assistantMessage.content ?? "";
break;
}
// Execute tool calls
const toolResults: object[] = [];
for (const tc of toolCalls) {
const fnName = tc.function?.name;
let args: Record<string, unknown> = {};
try {
args = JSON.parse(tc.function?.arguments ?? "{}");
} catch { /* ignore */ }
try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ }
send({ type: "tool_start", name: fnName, args });
let result = "";
if (fnName === "execute_sql") {
result = await executeSql(args.query as string);
} else if (fnName === "get_db_schema") {
result = DB_SCHEMA;
if (fnName === "call_api") {
result = await callFfApi(args.endpoint as string);
} else {
result = `Outil inconnu: ${fnName}`;
}
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: result,
});
toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
}
currentMessages.push(...toolResults);
}
// If model gave a direct answer in the loop, stream it character by character
if (directAnswer !== null) {
send({ type: "text", content: directAnswer });
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
@@ -506,22 +431,16 @@ export async function POST(req: NextRequest) {
return;
}
// Otherwise make a final streaming call after tool use
// Final streaming call after tool use
const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
if (!finalRes.ok) {
const errText = await finalRes.text();
send({ type: "error", message: `OpenRouter error: ${errText}` });
send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` });
controller.close();
return;
}
// Stream the response chunks
const reader = finalRes.body?.getReader();
if (!reader) {
controller.close();
return;
}
if (!reader) { controller.close(); return; }
const textDecoder = new TextDecoder();
let buffer = "";
@@ -530,26 +449,19 @@ export async function POST(req: NextRequest) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += textDecoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
if (raw === "[DONE]") continue;
try {
const chunk = JSON.parse(raw);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
send({ type: "text", content: delta });
}
if (chunk.usage) {
finalUsage = chunk.usage;
}
} catch { /* ignore malformed */ }
if (delta) send({ type: "text", content: delta });
if (chunk.usage) finalUsage = chunk.usage;
} catch { /* ignore */ }
}
}
@@ -558,12 +470,7 @@ export async function POST(req: NextRequest) {
totalCompletionTokens += finalUsage.completion_tokens ?? 0;
}
send({
type: "usage",
prompt_tokens: totalPromptTokens,
completion_tokens: totalCompletionTokens,
});
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
send({ type: "done" });
controller.close();
} catch (err) {