2 Commits

Author SHA1 Message Date
Jules Neny
d06c09a476 fix(bifrost): retire cerebras/gemma-4-31b du fallback tier RAPIDE (JSON invalide ~75% du temps) 2026-07-15 22:53:54 +02:00
Jules Neny
4291fe7529 feat(chatbot): branche Entraide/Réseaux/Jobs sur Bifrost au lieu de Mistral direct
Les 3 routes chatbot (chatbot.post.ts, chatbot-reseaux.post.ts, chatbot-taff.post.ts)
appellent désormais ${bifrostUrl}/v1/chat/completions (header x-bf-vk) au lieu de
api.mistral.ai direct. Tier RAPIDE par défaut (groq/llama-3.1-8b-instant + fallbacks
cerebras/gemini-flash-lite/cohere), tier APPROFONDI si body.mode === 'approfondi'
(prêt pour un futur toggle UI, hors-scope ici).

- server/utils/bifrost.ts (nouveau) : mutualise les 2 tiers pour les 3 routes.
- nuxt.config.ts : ajoute bifrostUrl/bifrostVk au runtimeConfig (mistralApiKey
  conservé, juste plus utilisé par ces 3 routes).
- chatbot.post.ts : garde son circuit breaker + logging stats_usage tels quels,
  mais logUsage reflète maintenant le provider/modèle réel ayant répondu
  (extra_fields de Bifrost) et ne calcule un coût que si ce provider est Mistral
  (les autres tiers Bifrost sont free-tier — cout_eur=0 sinon, pour ne pas fausser
  le circuit breaker budget).
- chatbot-reseaux.post.ts / chatbot-taff.post.ts : aucun circuit breaker/logging
  avant, aucun ajouté (asymétrie pré-existante préservée telle quelle).
- chatbot-v2.post.ts (orphelin) et chatbot-pensees.post.ts (proxy LightRAG,
  config runtime séparée) non touchés.

Testé en dev local contre Bifrost (IP Tailscale) : 1 appel réel par route,
réponses conformes, provider réel confirmé dans les logs de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 22:39:16 +02:00
5 changed files with 100 additions and 30 deletions

View File

@@ -16,6 +16,8 @@ export default defineNuxtConfig({
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
mistralApiKey: process.env.MISTRAL_API_KEY,
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
bifrostVk: process.env.BIFROST_VK,
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',

View File

@@ -6,6 +6,7 @@
// @ts-ignore — JSON import résolu par Rollup
import reseauxData from '../../public/data/reseaux-bifurcation.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
interface Structure {
id: string
@@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
const tier = pickBifrostTier(body?.mode)
const structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question)
@@ -82,18 +84,20 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
let mistralRaw: string
try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
{
method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 700,
response_format: { type: 'json_object' },

View File

@@ -7,6 +7,7 @@
// @ts-ignore — JSON import résolu par Vite/Rollup
import taffData from '../../public/data/plateformes-taff.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
interface PlateformeMinimal {
id: string
@@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const tier = pickBifrostTier(body?.mode)
// Données bundlées statiquement à la compilation (import JSON)
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
@@ -91,20 +93,22 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) {
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
}
let mistralRaw: string
try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
{
method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 700,
response_format: { type: 'json_object' },

View File

@@ -19,6 +19,7 @@
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -131,18 +132,19 @@ async function logUsage(params: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
model: string
tokensIn: number
tokensOut: number
coutEur: number
}) {
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try {
await $fetch(logUrl, {
method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
model,
endpoint: 'chatbot',
tokens_in: tokensIn,
tokens_out: tokensOut,
@@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
const tier = pickBifrostTier(body?.mode)
if (!question || question.length < 3) {
throw createError({
@@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => {
JSON.stringify(fichesContext, null, 0),
)
// 6. Appel Mistral Small
const mistralApiKey = config.mistralApiKey as string
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!mistralApiKey) {
if (!bifrostVk) {
throw createError({
statusCode: 500,
statusMessage: 'Clé API Mistral manquante.',
statusMessage: 'Clé Bifrost manquante.',
})
}
let mistralRaw: string
let tokensIn = 0
let tokensOut = 0
let realModel = tier.model
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'x-bf-vk': bifrostVk,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistral-small-latest',
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
}),
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
realModel =
bifrostRes.extra_fields?.resolved_model_used ??
(bifrostRes.extra_fields?.provider
? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}`
: tier.model)
} catch (e: any) {
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
}
// 8. Log usage (non bloquant)
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
// Coût réel calculable seulement si le provider ayant répondu est Mistral —
// les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier,
// donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable.
const isMistral = realModel.toLowerCase().startsWith('mistral')
const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0
logUsage({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
model: realModel,
tokensIn,
tokensOut,
coutEur,

47
server/utils/bifrost.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Bifrost — gateway LLM (remplace les appels directs Mistral)
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
* Auth : header x-bf-vk
*
* 2 tiers validés (Mission M3, build Bifrost) :
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
*
* ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash)
* ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux)
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
* réel (prompt + ~20 fiches), le modèle part en boucle de répétition dégénérée ("1 1 1...")
* dans ~75% des cas où il sert de fallback, produit un JSON invalide, et Bifrost ne bascule
* PAS plus loin dans la chaîne (HTTP 200 côté provider = pas une erreur pour Bifrost). Bug
* constaté en prod sur chatbot-reseaux (1 échec/3), reproduit 3/4 sur appel direct Bifrost.
* Cerebras reste configuré dans Bifrost (dispo pour tier CODE ou reconfiguration future),
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
*/
export const BIFROST_TIER_RAPIDE = {
model: 'groq/llama-3.1-8b-instant',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash-lite',
'cohere/command-r-08-2024',
],
}
export const BIFROST_TIER_APPROFONDI = {
model: 'groq/llama-3.3-70b-versatile',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash',
'mistral/mistral-large-latest',
'cohere/command-r-plus-08-2024',
],
}
/** Sélectionne le tier selon le param optionnel body.mode. */
export function pickBifrostTier(mode?: string) {
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
}
export interface BifrostChatResponse {
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
extra_fields?: { provider?: string; resolved_model_used?: string }
}